iPhone 15
Macintosh; Intel Mac OS X · screen: 393px'mobile''ios'With Safari’s “Request Desktop Website” even an iPhone claims to be a Mac. Multitouch plus the screen-size cross-check still says mobile.
In React CSR apps and Next.js SSR alike, know exactly whether your user is on a phone, tablet, or desktop, and which OS. Zero dependencies, iPads unmask themselves, frozen user agents don’t matter, and there is not a single hydration error.
At a glance
Each screen shows what useDevice() returns on that device — including the ones that lie about themselves.
Macintosh; Intel Mac OS X · screen: 393px'mobile''ios'With Safari’s “Request Desktop Website” even an iPhone claims to be a Mac. Multitouch plus the screen-size cross-check still says mobile.
Linux; Android 14; K'mobile''android'Chrome froze the UA — every Android reports model “K”. Client Hints still nail it.
Macintosh; Intel Mac OS X 10_15_7'tablet''ios'Masquerades as a Mac since iPadOS 13. Multitouch unmasking says tablet anyway.
Macintosh; Intel Mac OS X 10_15_7 · maxTouchPoints: 0'desktop''macos'A real Mac sending the exact same UA as the iPad above. The multitouch cross-check (maxTouchPoints: 0) is what tells them apart.
Windows NT 10.0 · maxTouchPoints: 10'desktop''windows'A touchscreen doesn’t fool it — touch is only consulted for the Apple masquerade. Stays desktop.
Linux; Android 11; SHIELD Android TV'desktop''android'An Android UA without the Mobile token would normally land in the tablet bucket, but TV markers are checked first — desktop is the best fit for a 10-foot UI.
Live demo
This page is server-rendered by Next.js. The left panel is frozen at the hydration first paint — exactly what the server sent. The right panel is what the hook knows right now.
Always desktop / unknown, on every device — that is why server HTML and client HTML can never disagree.
Corrected in a single render right after hydration. isTouchPrimary and orientation keep updating live.
Open this page on a phone, or reload with DevTools device emulation: the left panel stays desktop while the right one tells the truth — and the console logs zero hydration errors.
Usage
From a one-line boolean to the framework-free engine — each import ships only what it actually needs.
useDevice() returns type, os, boolean sugar, and the live fields. The server render and the hydration first paint always agree by construction, so you never write typeof window guards — branch on isHydrated only when you want to hide the one-render correction.
import { useDevice } from 'react-device-check';
export default function Page() {
const { type, os, isMobile, isHydrated } = useDevice();
// Server render & hydration first paint: type = 'desktop', isHydrated = false.
// One render later the real device shows up — no hydration mismatch, ever.
if (!isHydrated) return <Skeleton />;
if (isMobile && os === 'ios') return <AppStoreBanner />;
return <p>You are on a {type} running {os}.</p>;
}The static hooks are listener-free and maximally tree-shakeable: importing only useIsMobile and useOS drops the reactive store entirely and ships ~1.1 kB. Perfect for OS-specific store buttons.
import { useIsMobile, useOS } from 'react-device-check';
export default function DownloadButton() {
const isMobile = useIsMobile(); // boolean only — ~1.1 kB total
const os = useOS(); // 'ios' | 'android' | ...
if (isMobile && os === 'ios') return <AppStoreButton />;
if (isMobile && os === 'android') return <PlayStoreButton />;
return <DesktopDownloadButton />;
}detectDevice() is the pure decision tree behind the hooks — no React, no globals. Inject a UA string (or Client Hints) and get the same deterministic verdict in middleware, on servers, or in tests.
import { detectDevice } from 'react-device-check';
export function middleware(request: Request) {
// No React, no globals — inject any signals you have
const { type } = detectDevice({
ua: request.headers.get('user-agent') ?? '',
});
if (type === 'mobile') {
// e.g. rewrite to the lightweight variant
}
}Why
iPads report as tablets even behind the macOS desktop UA (MacIntel + multitouch unmasking). Android tablets follow the official Mobile-token rule; Samsung DeX and in-app WebViews are handled.
Trusts navigator.userAgentData on Chromium — immune to user-agent freezing — and falls back to UA parsing everywhere else.
Server render and hydration first paint always match, so React 18/19 never log a hydration mismatch. The hook corrects itself in one post-hydration render.
Zero runtime dependencies, dual ESM/CJS. Importing only useIsMobile ships ~1.1 kB and drops the reactive store entirely — budgets are enforced in CI.
type and os stay stable for the session, while isTouchPrimary and orientation update live via matchMedia — covering foldables, DeX docking, and iPad Stage Manager.
Beyond 76 unit tests, a Playwright matrix — iPhone 15, iPad Pro, Galaxy S24, Galaxy Tab S9, desktop Chrome and Safari — verifies detection and zero hydration errors.
Comparison
react-device-detect computes import-time constants from the UA, which crashes or mismatches under SSR, misreports iPads as desktops, never updates, and ships ~13 kB gzip that cannot be tree-shaken. It has been unmaintained since 2023, and its parser dependency moved to AGPL. react-device-check is a maintained MIT replacement designed around today’s platform realities.
API
| API | Description |
|---|---|
| useDevice() | Full snapshot: type, os, boolean sugar, live isTouchPrimary / orientation, and isHydrated. |
| useDeviceType() | 'mobile' | 'tablet' | 'desktop' — static per session, listener-free. |
| useIsMobile() · useIsTablet() · useIsDesktop() | Boolean sugar — maximally tree-shakeable; importing only these drops the reactive store. |
| useOS() | 'ios' | 'android' | 'windows' | 'macos' | 'linux' | 'unknown'. |
| detectDevice(input?, options?) | The pure engine — no React required, every signal injectable. Great for servers and tests. |
Full API reference and known limitations live in the GitHub README.