preact-device
v1.0.0-beta.0
Published
Modern, minimal device detection for Preact (and React via preact/compat). ~563B brotli, SSR-safe, tree-shakeable.
Maintainers
Readme
preact-device
Modern, minimal device detection for Preact (and React via preact/compat).
~700 B gzip · 1 hook · 1 function · no peer depsWhy this exists
react-device-detect is the de-facto incumbent in this space. It's also:
- ~54 KB raw / ~17 KB gzip (34 KB lib + 20 KB
ua-parser-jsdep) - Last published February 2023 — basically abandoned
- CommonJS bundle, eager module-level evaluation — hard to tree-shake; importing
isMobilepulls all 38 booleans, 12 view components, and the entireua-parser-jsregex table - No SSR strategy — top-level booleans are computed at import time. On the server they all evaluate against an empty UA → all false. Client hydration mismatches whenever real device ≠ desktop.
- Class-based view components (
<MobileView>,<IOSView>, …, 12 of them) that wrap{isMobile && children}. Idiomatic conditional rendering doesn't need a library for this. - React-only (uses
React.Component, peer-depsreact ≥ 0.14)
preact-device covers the same common cases (form factor + 5 OSes + 4 browsers + iPad-as-Mac feature detection) in <2% of the bundle size, with first-class SSR support and a hook-first API. It works with Preact directly and with React via preact/compat.
Comparison
| | react-device-detect | preact-device |
| ------------------------ | ------------------- | ------------------ |
| Bundle (raw) | 34 KB (+20 KB dep) | ~1.5 KB |
| Bundle (gzip) | ~17 KB | ~0.7 KB |
| Dependencies | ua-parser-js | none |
| Peer deps | react, react-dom| preact |
| Module format | CommonJS | ESM |
| Tree-shakeable | No (module state) | Yes (no module-level state) |
| SSR-safe | No (eager eval) | Yes (pure function + hydration-aware hook) |
| iPad-as-Mac detection | Yes | Yes |
| Form factor selectors | 8 | 5 (focused) |
| OS selectors | 5 | 5 |
| Browser selectors | 13 (incl. niche) | 4 + raw UA |
| View components | 12 class components | 0 (use &&) |
| Hooks | 3 | 1 |
| Last meaningful update | 2023 | maintained |
Bundle impact
Measured with esbuild --bundle --minify --format=esm (preact externalized), then compressed:
| Format | Size | | ----------------- | --------- | | Minified JS | 1,225 B | | gzip -9 | 658 B | | brotli -q 11 | 563 B |
For comparison: react-device-detect itself is ~17 KB gzip (≈14 KB brotli) before its ua-parser-js dependency, plus another ~6 KB brotli for that. So preact-device is roughly 35× smaller for the cases both libraries cover.
Note on duplication across chunks. Because there's no module-level state and the lib is pure ESM, each consumer chunk that imports
useDevicewould get its own copy unless your bundler (Vite/Rollup) hoists it into a shared chunk. With one or two consumers, hoisting kicks in and you pay it once. With many isolated islands, worst case is ~563 B per island chunk. Still smaller than a single SVG icon — but worth knowing if you're shipping dozens of independent islands.
Install
npm install preact-device
# or in a Preact monorepo, copy lib/preact-device/index.ts and adjust the import path.API
useDevice(ssrUA?: string): Device
Hook for use inside components. SSR-safe by design:
- On the server (no
navigator), returns sensible desktop defaults so the SSR'd HTML is deterministic. - On the client, re-evaluates with the real navigator after mount. Costs one extra render on hydration but never produces a hydration mismatch warning.
import { useDevice } from 'preact-device'
function Nav() {
const { isMobile, isTouchDevice } = useDevice()
return isMobile ? <MobileNav /> : <DesktopNav touch={isTouchDevice} />
}For frameworks that pass UA from request headers (Astro, Remix, Next), pass it through to skip the post-hydration re-render:
// Astro server: `<Nav client:load ssrUA={Astro.request.headers.get('user-agent')!} />`
function Nav({ ssrUA }: { ssrUA?: string }) {
const device = useDevice(ssrUA)
// server and first client render agree → no hydration re-render
}getDevice(ua?: string): Device
Pure synchronous function. Use outside components, in module-level code, in route guards, in service workers — anywhere you don't have hooks.
import { getDevice } from 'preact-device'
// Client (reads navigator.userAgent):
if (getDevice().isIOS) loadIOSPolyfill()
// SSR (pass UA explicitly):
export async function GET({ request }) {
const device = getDevice(request.headers.get('user-agent') ?? undefined)
return Response.json({ layout: device.isMobile ? 'compact' : 'full' })
}Device shape
interface Device {
// Form factor
isMobile: boolean // phone OR tablet
isTablet: boolean // tablet only (iPad-as-Mac counts)
isPhone: boolean // phone only
isDesktop: boolean // not mobile/tablet
isTouchDevice: boolean // any touch input available (independent of form factor)
// OS
isIOS: boolean // includes iPad-as-Mac via maxTouchPoints check
isAndroid: boolean
isMacOS: boolean
isWindows: boolean
isLinux: boolean
// Browser
isSafari: boolean
isChrome: boolean
isFirefox: boolean
isEdge: boolean
isMobileSafari: boolean // isSafari && isIOS — most "Safari quirk" code means this
// Escape hatch
ua: string // raw UA string for custom checks
}Migration from react-device-detect
Most of react-device-detect's API maps cleanly:
| react-device-detect | preact-device |
| ---------------------------- | ------------------------------------ |
| import { isMobile } | useDevice().isMobile |
| <MobileView>{x}</MobileView> | {useDevice().isMobile && x} |
| <IOSView>{x}</IOSView> | {useDevice().isIOS && x} |
| useDeviceSelectors(ua) | getDevice(ua) |
| useDeviceData(ua) | getDevice(ua) |
| parseUserAgent(ua) | getDevice(ua) |
| setUserAgent(ua) | (not needed — pass to getDevice) |
| withOrientationChange | use matchMedia('(orientation: portrait)') directly |
What's intentionally not included
These exist in react-device-detect but are out of scope here:
- Niche browsers:
isMIUI,isYandex,isSamsungBrowser,isOpera,isIE,isChromium,isEdgeChromium,isLegacyEdge,isElectron. If you need these, checkdevice.uadirectly with a regex — it's two lines. - Niche device types:
isSmartTV,isConsole,isWearable,isEmbedded. Same answer —device.uais exposed for custom matching. - Version strings:
osVersion,browserVersion,fullBrowserVersion,engineName,mobileVendor,mobileModel. These require a full UA-parser table (~20 KB on its own) and are usually used for analytics, not runtime decisions. UsegetDevice().ua+ your own regex if you need them, or pull inua-parser-jsdirectly when accuracy matters more than bundle size. useMobileOrientation: orientation is one line of native API:
Not worth wrapping in a hook.const isPortrait = window.matchMedia('(orientation: portrait)').matches- View components:
<MobileView>,<IOSView>, etc. Conditional rendering with&&is the JSX idiom. A wrapper component just adds noise:// react-device-detect <MobileView>{content}</MobileView> // preact-device {isMobile && content}
If you genuinely need a 12-class-component API surface, stick with react-device-detect. For everyone else, preact-device covers the realistic 90% of use cases at 2% of the bundle cost.
Design decisions
Why no module-level boolean exports (export const isMobile = ...). They look convenient but they're a SSR landmine: they evaluate at import time, on whatever environment is importing them. On the server that's an empty navigator → all false → wrong layout sent to the client → hydration mismatch when the real device is anything other than desktop. The only way to get correct values is to call getDevice() from inside the render path or useDevice() from inside a hook.
Why a single Device object instead of individual selectors. Tree-shaking. With separate selectors, importing one pulls in all the OS/browser/form-factor logic anyway because they share the parsing layer. With one function returning everything, you import one function and it has zero unused-export overhead. The destructuring (const { isMobile } = useDevice()) gives you the same call-site ergonomics.
Why no view components. <MobileView>{x}</MobileView> is {isMobile && x} plus 12 class component definitions in the bundle. The shorter form is also more flexible (works with fragments, arrays, conditionals like {isMobile ? <A/> : <B/>}).
Why getDevice() instead of always using useDevice(). Hooks have render-cycle semantics. Module-level code, event handlers, async callbacks, and SSR routes don't. A pure function works everywhere; the hook is sugar for the common in-component case.
Why pure UA regex + minimal feature detection. ua-parser-js is 20 KB to handle ~300 browsers, most of which you'll never see. The big-four browsers and five major OSes account for >99% of traffic; a 30-line regex covers them. The one place feature detection actually matters (iPad spoofing as Mac) gets navigator.maxTouchPoints — the only modern reliable signal.
License
MIT
