Build a hook that reports whether the user's operating system or browser currently prefers a dark color scheme. CSS can react to this setting with @media (prefers-color-scheme: dark); JavaScript gets the same signal from window.matchMedia. Your hook should turn that browser signal into React state and keep it current when the preference changes while the page is open.
function useDarkMode(defaultValue?: boolean): boolean;
defaultValue defaults to false. It is the safe value to return when window.matchMedia is unavailable, including server rendering. Once the hook mounts in a supporting browser, the media query's current matches value becomes the source of truth.
function ThemeStatus() {
const isDarkMode = useDarkMode();
return <p>{isDarkMode ? 'Dark preference' : 'Light preference'}</p>;
}
// System preference when the component mounts: dark
renderHook(() => useDarkMode()).result.current; // true
// The same mounted hook updates when the preference changes:
// dark -> light => result.current becomes false
// light -> dark => result.current becomes true
(prefers-color-scheme: dark). Read its current matches value after mount, then listen for its change event.defaultValue is true but the media query currently reports false, the hook must settle on false.change handler from the same MediaQueryList object when the component unmounts.window or window.matchMedia is unavailable, return defaultValue and do not throw.<html>, and storing an override in localStorage are intentionally out of scope.You'll treat the browser's dark-scheme media query as a tiny external signal: synchronize React state from its current snapshot, subscribe to future changes, and disconnect that subscription when the component leaves.
The user's color-scheme preference does not belong to your component. It lives in the browser and can change while the component is mounted—for example, when the operating system switches to a scheduled night theme. Reading the preference once answers “what is it now?” but not “tell me when it changes.” The hook needs both halves: the media query's current matches value and its change event.
Picture a three-link pipeline. The operating system owns the setting. matchMedia('(prefers-color-scheme: dark)') exposes that setting as a MediaQueryList. React state mirrors the list so a component re-renders whenever the list reports a different value.
The tempting version reads matches in the state initializer and stops:
const { useState } = require('react');
function useDarkMode(defaultValue = false) {
const [isDarkMode] = useState(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return Boolean(defaultValue);
}
return window.matchMedia('(prefers-color-scheme: dark)').matches;
});
return isDarkMode;
}
It passes an initial light-or-dark check, which makes the bug easy to miss. But a state initializer runs once. When the browser later changes from light to dark, nothing calls a setter, so React keeps returning the old value forever.
const { useEffect, useState } = require('react');
const DARK_MODE_QUERY = '(prefers-color-scheme: dark)';
function useDarkMode(defaultValue = false) {
// Starting from a caller-controlled value is safe when this render happens
// without browser APIs, as it does during server rendering.
const [isDarkMode, setIsDarkMode] = useState(Boolean(defaultValue));
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return undefined;
}
const mediaQuery = window.matchMedia(DARK_MODE_QUERY);
const handleChange = (event) => setIsDarkMode(event.matches);
// The preference may differ from the server/default snapshot by mount time.
setIsDarkMode(mediaQuery.matches);
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
return isDarkMode;
}
module.exports = { useDarkMode };
The state now has a setter, and the effect owns the entire browser subscription. It obtains one MediaQueryList, immediately reconciles state from its current matches snapshot, attaches one handler, and closes over that same list and handler for cleanup. The empty dependency array is appropriate because the query is a fixed constant and the subscription should live for exactly one mount.
Suppose the server/default value is light (false), but the browser currently prefers dark:
useState(Boolean(false)) returns false without touching window, so this step is safe even outside a browser.matchMedia('(prefers-color-scheme: dark)') and receives a list whose matches is true.setIsDarkMode(true) updates the stale default, so the component re-renders with dark mode active.handleChange for the list's change event.matches: false; the handler calls setIsDarkMode(false), producing the next render.MediaQueryList, so the abandoned component cannot receive later changes.Mounting and each preference change take O(1) time and the hook stores O(1) state. It creates one media-query subscription per mounted hook instance.
matches once. A correct initial value can hide the fact that the hook never updates. Fix: subscribe to the MediaQueryList object's change event and drive a state setter from event.matches.window. The preference change belongs to the returned MediaQueryList, not to a generic window event. Fix: call mediaQuery.addEventListener('change', handler).matchMedia during render and placing that object in effect dependencies can tear down and rebuild the listener repeatedly. Fix: acquire it inside a mount-only effect.removeEventListener('change', () => ...) creates a new function and leaves the original subscribed. Fix: name handleChange inside the effect and pass the same reference to add and remove.window before the effect. A state initializer that calls window.matchMedia throws during server rendering and can make server/client markup disagree. Fix: initialize from defaultValue, then reconcile after mount.'light', 'dark', or 'system'—over this signal, persist the explicit choice, and fall back to the media query only in system mode.useMediaQuery(query, defaultValue) and implement dark mode as useMediaQuery('(prefers-color-scheme: dark)').MediaQueryList through useSyncExternalStore instead of giving each hook instance its own state and listener.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a hook that reports whether the user's operating system or browser currently prefers a dark color scheme. CSS can react to this setting with @media (prefers-color-scheme: dark); JavaScript gets the same signal from window.matchMedia. Your hook should turn that browser signal into React state and keep it current when the preference changes while the page is open.
function useDarkMode(defaultValue?: boolean): boolean;
defaultValue defaults to false. It is the safe value to return when window.matchMedia is unavailable, including server rendering. Once the hook mounts in a supporting browser, the media query's current matches value becomes the source of truth.
function ThemeStatus() {
const isDarkMode = useDarkMode();
return <p>{isDarkMode ? 'Dark preference' : 'Light preference'}</p>;
}
// System preference when the component mounts: dark
renderHook(() => useDarkMode()).result.current; // true
// The same mounted hook updates when the preference changes:
// dark -> light => result.current becomes false
// light -> dark => result.current becomes true
(prefers-color-scheme: dark). Read its current matches value after mount, then listen for its change event.defaultValue is true but the media query currently reports false, the hook must settle on false.change handler from the same MediaQueryList object when the component unmounts.window or window.matchMedia is unavailable, return defaultValue and do not throw.<html>, and storing an override in localStorage are intentionally out of scope.You'll treat the browser's dark-scheme media query as a tiny external signal: synchronize React state from its current snapshot, subscribe to future changes, and disconnect that subscription when the component leaves.
The user's color-scheme preference does not belong to your component. It lives in the browser and can change while the component is mounted—for example, when the operating system switches to a scheduled night theme. Reading the preference once answers “what is it now?” but not “tell me when it changes.” The hook needs both halves: the media query's current matches value and its change event.
Picture a three-link pipeline. The operating system owns the setting. matchMedia('(prefers-color-scheme: dark)') exposes that setting as a MediaQueryList. React state mirrors the list so a component re-renders whenever the list reports a different value.
The tempting version reads matches in the state initializer and stops:
const { useState } = require('react');
function useDarkMode(defaultValue = false) {
const [isDarkMode] = useState(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return Boolean(defaultValue);
}
return window.matchMedia('(prefers-color-scheme: dark)').matches;
});
return isDarkMode;
}
It passes an initial light-or-dark check, which makes the bug easy to miss. But a state initializer runs once. When the browser later changes from light to dark, nothing calls a setter, so React keeps returning the old value forever.
const { useEffect, useState } = require('react');
const DARK_MODE_QUERY = '(prefers-color-scheme: dark)';
function useDarkMode(defaultValue = false) {
// Starting from a caller-controlled value is safe when this render happens
// without browser APIs, as it does during server rendering.
const [isDarkMode, setIsDarkMode] = useState(Boolean(defaultValue));
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return undefined;
}
const mediaQuery = window.matchMedia(DARK_MODE_QUERY);
const handleChange = (event) => setIsDarkMode(event.matches);
// The preference may differ from the server/default snapshot by mount time.
setIsDarkMode(mediaQuery.matches);
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
return isDarkMode;
}
module.exports = { useDarkMode };
The state now has a setter, and the effect owns the entire browser subscription. It obtains one MediaQueryList, immediately reconciles state from its current matches snapshot, attaches one handler, and closes over that same list and handler for cleanup. The empty dependency array is appropriate because the query is a fixed constant and the subscription should live for exactly one mount.
Suppose the server/default value is light (false), but the browser currently prefers dark:
useState(Boolean(false)) returns false without touching window, so this step is safe even outside a browser.matchMedia('(prefers-color-scheme: dark)') and receives a list whose matches is true.setIsDarkMode(true) updates the stale default, so the component re-renders with dark mode active.handleChange for the list's change event.matches: false; the handler calls setIsDarkMode(false), producing the next render.MediaQueryList, so the abandoned component cannot receive later changes.Mounting and each preference change take O(1) time and the hook stores O(1) state. It creates one media-query subscription per mounted hook instance.
matches once. A correct initial value can hide the fact that the hook never updates. Fix: subscribe to the MediaQueryList object's change event and drive a state setter from event.matches.window. The preference change belongs to the returned MediaQueryList, not to a generic window event. Fix: call mediaQuery.addEventListener('change', handler).matchMedia during render and placing that object in effect dependencies can tear down and rebuild the listener repeatedly. Fix: acquire it inside a mount-only effect.removeEventListener('change', () => ...) creates a new function and leaves the original subscribed. Fix: name handleChange inside the effect and pass the same reference to add and remove.window before the effect. A state initializer that calls window.matchMedia throws during server rendering and can make server/client markup disagree. Fix: initialize from defaultValue, then reconcile after mount.'light', 'dark', or 'system'—over this signal, persist the explicit choice, and fall back to the media query only in system mode.useMediaQuery(query, defaultValue) and implement dark mode as useMediaQuery('(prefers-color-scheme: dark)').MediaQueryList through useSyncExternalStore instead of giving each hook instance its own state and listener.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.