Topic 10.2
Useful Hook Recipes
In one line
Seven small hooks cover jobs almost every app has: useToggle, useLocalStorage, useDebounce, useOnlineStatus, useMediaQuery, usePrevious and useInterval; building them yourself teaches the patterns behind every hook library.
Think of it like this
A kitchen drawer of gadgets. A bottle opener, a timer, a measuring cup: each one does one small job, and you reach for it without thinking. These seven hooks are the gadgets of a React app. Each is under 20 lines, and each teaches one pattern you'll reuse when you write your own.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Recipe hook
- A small, general custom hook that solves one common job, like toggling or debouncing.
- Lazy initialiser
- Passing a function to
useState(() => ...)so the start value is computed only on the first render. - localStorage
- Browser storage for small strings that survives reloads. Same origin only, about 5 MB, read and written synchronously.
- Debounce
- Wait until something stops changing for a set time, then act once.
- navigator.onLine
- A browser value that is
falsewhen the device has no network connection. - matchMedia
- A browser API that tells you whether a CSS media query matches right now and fires
changewhen that flips. - Stale closure
- A function that still sees old values because it was created during an earlier render.
- Effect event
- A function made with
useEffectEventthat an effect can call; it always reads the latest props and state but is not a dependency.
Step by step
01useToggle: name the action
The smallest useful hook. The updater form makes it safe even when called twice before a render.
export function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((v) => !v), []);
return [on, toggle];
}02useLocalStorage: read once, write on change
Read in the lazy initialiser so a reload restores the value without a flash of the default. Write in an effect. Catch errors: storage can throw (private mode, quota full) and old data can be invalid JSON.
export function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const saved = localStorage.getItem(key);
return saved !== null ? JSON.parse(saved) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// storage full or blocked: keep working in memory
}
}, [key, value]);
return [value, setValue];
}03useDebounce: every change restarts the timer
Each new value runs the cleanup (cancelling the old timer) and starts a fresh one. Only a value that survives delay ms without a change reaches setDebounced.
export function useDebounce(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id); // a new value cancels the old timer
}, [value, delay]);
return debounced;
}04useOnlineStatus and useMediaQuery: subscribe and unsubscribe
Both read a browser value for the first render, subscribe to its change events in an effect, and remove the listeners in cleanup. Forget the cleanup and every mounted component leaks a listener.
export function useMediaQuery(query) {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const mql = window.matchMedia(query);
const onChange = () => setMatches(mql.matches);
onChange(); // the query itself may have changed since the first render
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, [query]);
return matches;
}05usePrevious: two versions
The old ref version returns the value from the previous render, and reads a ref during render. The state version returns the previous different value and stays pure. For most UIs ("price went up") the second meaning is what you want anyway.
// Old: reads a ref during render (the linter reports react-hooks/refs)
function usePreviousRef(value) {
const ref = useRef(undefined);
useEffect(() => { ref.current = value; });
return ref.current;
}
// Current: remember the previous *different* value in state
export function usePrevious(value) {
const [current, setCurrent] = useState(value);
const [previous, setPrevious] = useState(undefined);
if (!Object.is(value, current)) {
setPrevious(current); // allowed: React re-runs this render right away
setCurrent(value);
}
return previous;
}06useInterval: the reactive delay, the non-reactive tick
Only delay is a dependency. The callback is wrapped in useEffectEvent, so it can read the latest state without restarting the interval. Before React 19.2, the same idea used a ref that an effect kept updated.
export function useInterval(callback, delay) {
const onTick = useEffectEvent(callback); // React 19.2
useEffect(() => {
if (delay === null) return; // null pauses
const id = setInterval(() => onTick(), delay);
return () => clearInterval(id);
}, [delay]);
}
// Before 19.2: keep the latest callback in a ref
function useIntervalLegacy(callback, delay) {
const saved = useRef(callback);
useEffect(() => { saved.current = callback; });
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => saved.current(), delay);
return () => clearInterval(id);
}, [delay]);
}Try it yourself
- 1
Debounce for longer
In the debounce example, change the typing pause from 60 to 400 ms. Predict how many
searching for:lines you get now (each pause is longer than the 300 ms delay), then run it. - 2
Break the storage on purpose
In the localStorage example, change the pretend visit to
localStorage.setItem("theme", "dark")(no JSON quotes). Predict: what doesJSON.parse("dark")do, and what theme do you see? Thetry/catchfalls back to"light"instead of crashing. - 3
Pause the countdown
Add a
pausedstate and a Pause button to the countdown, and passpaused || left === 0 ? null : 100as the delay. The hook call never moves into anif; the condition goes into the hook.
Code & diagrams
Two toggles in one go cancel out, as they should, because `toggle` uses an updater. `usePrevious` remembers the price before the last change.
Preview
Console
Nothing logged yet.Expected console output
help shown: false
Price: 105 up from 100
Price: 85 down from 105The saved `"dark"` was read in the lazy initialiser, so the first render was already dark. Strings are stored with their quotes because the hook stores JSON. Hectal's preview keeps localStorage in memory, so it resets on each Run.
Preview
Console
Nothing logged yet.Expected console output
on load: Theme: dark, size: 16px
saved theme: "light"
saved size: 18Five changes, one search, about 300 ms after the last key. Type in the box yourself and watch the "Searched" part lag behind.
Preview
Console
Nothing logged yet.Expected console output
typing done
searching for: reactThe script fires fake `offline` and `online` events. In a real browser, turn on "Offline" in DevTools' Network panel. Open the example in a new tab and make the window narrower than 500 px to see the media query flip.
Preview
Console
Nothing logged yet.Expected console output
Offline: changes will sync later
OnlineThe tick reads the latest `left` without `left` being a dependency, so the interval starts once and stops when the delay becomes `null`. Change the delay to 1000 and click Faster mid-way: the interval keeps running and the new step is used at once.
Preview
Console
Nothing logged yet.Expected console output
interval started: 100 ms
tick: 3
tick: 2
tick: 1
tick: 0Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget the debounce cleanup
In useDebounce, delete return () => clearTimeout(id);.
Break #2
Write useInterval without a stable callback
Replace onTick with the callback itself and add it to the dependencies: setInterval(callback, delay) with [callback, delay].
Myth vs fact
Myth
I should install a hook library instead of writing these.
Fact
Libraries such as usehooks-ts or react-use are fine, but each recipe here is a few lines. Writing them once teaches you to judge a library's version (does it clean up? does it handle JSON errors? is it SSR-safe?).
Myth
useLocalStorage keeps two tabs in sync.
Fact
This version doesn't. Another tab changing the key fires a storage event in this tab, which you must subscribe to; Topic 10.3 shows the external-store version that handles it.
Myth
Debounce and throttle are the same.
Fact
Debounce waits for a pause, then acts once. Throttle acts at most once per interval while changes continue (useful for scroll or resize). Topic 24.5 compares them.
Myth
usePrevious with a ref is the official recipe.
Fact
It was popular, but it reads a ref during render, which the rules (and the react-hooks/refs lint rule) forbid. Storing the previous value in state is the pure version.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
useLocalStoragereading storage in the initialiser causes a hydration mismatch with server rendering: the server has nolocalStorageand renders the default. Server-safe versions read storage withuseSyncExternalStoreand agetServerSnapshotthat returns the default (Topic 10.3, Topic 20.6). - ▸
setStateduring render (theusePrevioustrick) is allowed only for the component's own state and only behind a condition that stops the loop. React throws away the JSX of that render and immediately re-renders before touching the DOM, so children never see the in-between values. - ▸
useEffectEventfunctions must only be called from inside effects (not passed to children or called during render), and they're not dependencies. React 19.2 made it stable, and the currenteslint-plugin-react-hooksknows it is not a dependency: if you list it, the linter warns that functions returned fromuseEffectEventmust not be included in the dependency array. - ▸
useMediaQueryrunningonChange()inside the effect fixes a subtle bug: ifquerychanges, the lazily initialised state still holds the old query's answer until the nextchangeevent. TheuseSyncExternalStoreversion in Topic 10.3 has no such gap.
Remember this
- 1
**
useToggle(initial)** returns[on, toggle]for anything that flips between two states: a menu, a password's show/hide, a dark-mode switch. Itstoggleuses an updater function (setOn((v) => !v), Topic 4.5) so fast double clicks never read a stale value. Pattern: wrap a state update behind a meaningful action name. - 2
**
useLocalStorage(key, initial)** works likeuseStatebut remembers the value inlocalStorage(the browser's small key-value storage that survives page reloads). It reads the saved value once, in a lazy initialiser (useState(() => ...), so the read doesn't happen on every render), and writes in an effect whenever the value changes. It stores JSON (JSON.stringify/JSON.parse) becauselocalStorageonly holds strings, and it wraps both intry/catch: storage can be full, disabled in private windows, or hold broken JSON. Pattern: sync state with an outside system in an effect. (In Hectal's preview,localStorageis in memory and resets on every Run.) - 3
**
useDebounce(value, delay)** returns a copy ofvaluethat only updates after the value has stopped changing fordelaymilliseconds. Debouncing means waiting for a pause before acting, like a lift that waits until people stop walking in before closing its doors. Typingreactfires five changes; the debounced value changes once, so you send one search request instead of five. Inside, an effect starts asetTimeoutand its cleanup cancels the previous timer every time the value changes (Topic 7.3). Pattern: cleanup cancels stale work. - 4
**
useOnlineStatus()** returnstrueorfalsefromnavigator.onLineand listens to the window'sonlineandofflineevents. **useMediaQuery(query)** returns whether a CSS media query such as"(max-width: 600px)"or"(prefers-reduced-motion: reduce)"matches, usingwindow.matchMediaand itschangeevent. Both follow the pattern subscribe in an effect, unsubscribe in cleanup. Topic 10.3 rewrites both withuseSyncExternalStore, which is the better tool for reading browser values. - 5
**
usePrevious(value)** returns the value from before the last change, so you can show "was 3, now 4" or animate a direction. The old version kept the value in a ref and returnedref.currentduring render; that breaks the rule "don't read refs during render" (Topic 8.1) and the current linter reports it. The version below keeps the previous different value in state and updates it during render when the input changes, the same "adjust state while rendering" trick React's docs recommend for reacting to prop changes. - 6
**
useInterval(callback, delay)** runscallbackeverydelaymilliseconds and stops whendelayisnull. A plainsetIntervalin an effect has a trap: the callback captures the state of the render that created it (a stale closure, see /javascript for closures). The classic fix stores the latest callback in a ref. Since React 19.2 the cleaner fix isuseEffectEvent(Topic 7.9): the interval calls an effect event that always sees the latest props and state, so the interval only restarts whendelaychanges. Pattern: separate the reactive part (delay) from the non-reactive part (what to do on each tick).
Explain it without notes
How does useDebounce make sure only the last value is used?
Why does useLocalStorage read storage in a lazy initialiser and write in an effect?
Why is a naive useInterval either stale or constantly restarting, and how does useEffectEvent fix it?
What is the difference between the ref-based and the state-based usePrevious?
Practice
Write useCounter as a recipe: useCounter({ min = 0, max = 10 } = {}) returns { count, inc, dec } and never leaves the range.
Write useWindowWidth() that returns window.innerWidth and updates on resize, with cleanup.
Use useDebounce and useLocalStorage together: a notes textarea that saves its text 500 ms after the user stops typing.
Trade-offs
- ↔
Small homemade hooks are easy to read and own; a library hook may handle more edge cases (SSR, cross-tab sync, Safari quirks). Copy the library's tests or behaviour when your own version starts growing options.
- ↔
Debouncing adds delay to every result. 200 to 400 ms suits search boxes; for saving drafts, longer is fine; for anything that must feel instant (filtering 50 local items), skip it.
- ↔
localStorageis synchronous and blocks the main thread; it suits small settings, not large data. Use IndexedDB for big or structured data (Topic 24.9).
Done when you can
I can write
useToggle,useDebounceanduseLocalStoragefrom memory.I always clean up timers and listeners that a hook creates.
I can explain the stale-closure problem in
useIntervaland fix it withuseEffectEvent.I know why the state-based
usePreviousis safer than the ref version.I pass conditions into a hook (
nulldelay) instead of calling it conditionally.