Read the user's OS-level
prefers-color-scheme, and stay in sync as they change it in their system settings.
export function useTheme(): ThemePreference {
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
return useMemo(
() => ({
theme,
isDarkMode: theme === 'dark',
isLightMode: theme === 'light',
}),
[theme],
);
}Why this one:
useSyncExternalStore, notuseEffect+useState. This is the hook React ships specifically for subscribing to a store that lives outside React —matchMediahere. It's the only approach that is safe under concurrent rendering: withuseEffect+useState, a mid-render OS preference change can be read inconsistently across a tree that suspends or is interrupted (tearing).useSyncExternalStoreguarantees every consumer sees the same value in a given render.getServerSnapshotreturnsundefined, not a guessed default. There is no correct guess on the server:matchMediadoes not exist there, and defaulting to'light'(or'dark') is a coin flip that mismatches the client half the time, which React's hydration reports as an error. Returningundefinedpushes the "what do I render before I know" decision to the consumer, where it belongs.windowis referenced directly ingetSnapshot/subscribe, unguarded. This is safe, not an oversight: React only callsgetServerSnapshotwhile rendering on the server, andsubscribeonly after the component has mounted on the client — sogetSnapshotandsubscribeare guaranteed to run in a browser every time, byuseSyncExternalStore's own contract, not by assumption. That guarantee depends on both functions staying private module closures that are never exported or called from anywhere except theuseSyncExternalStorecall above; if a future change ever calls either of them directly (e.g. to expose a synchronous "read once" escape hatch), it needs its own SSR guard.isDarkMode/isLightModeas convenience booleans, not a thirdresolvedflag. There are only two real themes, so both booleans beingfalseat once is already unambiguous evidence thatthemehasn't resolved yet — a consumer that only cares about the common case never has to touchundefinedat all, whilethemestays available for anyone who wants the tri-state value directly (e.g.data-theme={theme}).- The returned object is built with
useMemo, keyed ontheme— not returned directly fromgetSnapshot.useSyncExternalStorerequiresgetSnapshotto return a value that is reference-stable (Object.is-equal) across calls when nothing changed; a plain string satisfies that for free, but a freshly built{ theme, isDarkMode, isLightMode }object would not — every call would be a "new" snapshot, which can cause React to think the store changed on every render. KeepinggetSnapshotreturning the cheap primitive and deriving the richer, memoized object one layer up in the hook body gets the ergonomic API without breaking that contract. See ADR 0003. - One
MediaQueryListper subscription, not a shared module-level one.subscribecallswindow.matchMediaitself rather than caching a shared list, so each subscriber'saddEventListener/removeEventListenerpair is symmetric and the hook works correctly if used from multiple components at once, each mounting/unmounting independently.
const [theme, setTheme] = useState<Theme>(() => getSnapshot());
useEffect(() => {
const mql = window.matchMedia(QUERY);
const handler = () => setTheme(getSnapshot());
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);- The obvious first approach, and fine for a component that renders in isolation.
- Not safe under React's concurrent features: state set from an effect can
desync from a store update that happened during a render React later discards
and retries, which is exactly the class of bug
useSyncExternalStoreexists to close. - Also has no SSR story on its own —
getSnapshotreadswindowdirectly, so the lazyuseStateinitializer throws server-side unless separately guarded. Rejected in favor of the hook designed for this.
Polling matchMedia(...).matches on an interval or on window.resize instead
of listening for the query's own change event.
prefers-color-schemechanges when the OS theme changes (e.g. a scheduled dark-mode switch, or the user toggling System Settings), which fires noresizeevent at all. Polling would miss the transition entirely until something else happened to trigger a re-render. Rejected: wrong event to listen for.
Creating a single MediaQueryList and listener at module scope, shared by every
useTheme() call, instead of one per subscription.
- Marginally fewer
matchMediacalls, but couples every consumer's lifecycle to module state that outlives any single component, which complicates testing (module state has to be reset between tests) and server-side rendering (module scope can be shared across requests in some runtimes, leaking one request's subscribers into another).subscribeis cheap enough that per-call is the simpler, safer default.
const getSnapshot = (): ThemePreference => {
const theme = window.matchMedia(QUERY).matches ? 'dark' : 'light';
return {
theme,
isDarkMode: theme === 'dark',
isLightMode: theme === 'light',
};
};- Simpler at first glance — one function instead of
getSnapshot+useMemo— but every call allocates a new object, soObject.is(prev, next)is nevertrueeven when the OS preference hasn't changed. React's own documentation calls this out as a correctness bug (it can manifest as an infinite re-render loop), not just a performance concern. Rejected: violatesuseSyncExternalStore's contract.
themeisundefineduntil the client commits its first snapshot. This is intentional (see above), not a bug —isDarkModeandisLightModeare bothfalsein that window, so most consumers never need to checkthemedirectly.- Does not persist or default a user override. This hook reports the OS
preference only; layering a manual light/dark toggle with
localStorage(and falling back to this hook) is a separate concern for the consumer.