Command Palette

Search for a command to run...

Hectal
PHASE 10Intermediate React 16.8+ ~36 min· topic 2 of 6

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 false when the device has no network connection.
matchMedia
A browser API that tells you whether a CSS media query matches right now and fires change when 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 useEffectEvent that 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.

useToggle.jswhole filejsx
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.

useLocalStorage.jswhole filejsx
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.

useDebounce.jswhole filejsx
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;
}
useDebounce: every change restarts the timerdiagram
Rendering diagram…

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.

useMediaQuery.jswhole filejsx
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.

usePrevious.jswhole filejsx
// 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.

useInterval.jswhole filejsx
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. 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. 2

    Break the storage on purpose

    In the localStorage example, change the pretend visit to localStorage.setItem("theme", "dark") (no JSON quotes). Predict: what does JSON.parse("dark") do, and what theme do you see? The try/catch falls back to "light" instead of crashing.

  3. 3

    Pause the countdown

    Add a paused state and a Pause button to the countdown, and pass paused || left === 0 ? null : 100 as the delay. The hook call never moves into an if; the condition goes into the hook.

Code & diagrams

useToggle and usePrevious New tab

Two toggles in one go cancel out, as they should, because `toggle` uses an updater. `usePrevious` remembers the price before the last change.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

help shown: false
Price: 105 up from 100
Price: 85 down from 105
useLocalStorage: state that survives a reload New tab

The 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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

on load: Theme: dark, size: 16px
saved theme: "light"
saved size: 18
useDebounce: one search instead of five New tab

Five changes, one search, about 300 ms after the last key. Type in the box yourself and watch the "Searched" part lag behind.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

typing done
searching for: react
useOnlineStatus and useMediaQuery New tab

The 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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Offline: changes will sync later
Online
useInterval with useEffectEvent React 19.2+ New tab

The 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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

interval started: 100 ms
tick: 3
tick: 2
tick: 1
tick: 0

Break 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);.

terminal
$ Console
── what you'll see ──
searching for: r
typing done
searching for: re
searching for: rea
searching for: reac
searching for: react

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].

terminal
$ Console
── what you'll see ──
interval started: 100 ms
tick: 3
interval started: 100 ms
tick: 2
interval started: 100 ms
tick: 1
interval started: 100 ms
tick: 0

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.

  • ▸

    useLocalStorage reading storage in the initialiser causes a hydration mismatch with server rendering: the server has no localStorage and renders the default. Server-safe versions read storage with useSyncExternalStore and a getServerSnapshot that returns the default (Topic 10.3, Topic 20.6).

  • ▸

    setState during render (the usePrevious trick) 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.

  • ▸

    useEffectEvent functions 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 current eslint-plugin-react-hooks knows it is not a dependency: if you list it, the linter warns that functions returned from useEffectEvent must not be included in the dependency array.

  • ▸

    useMediaQuery running onChange() inside the effect fixes a subtle bug: if query changes, the lazily initialised state still holds the old query's answer until the next change event. The useSyncExternalStore version in Topic 10.3 has no such gap.

Remember this

  1. 1

    **useToggle(initial)** returns [on, toggle] for anything that flips between two states: a menu, a password's show/hide, a dark-mode switch. Its toggle uses 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. 2

    **useLocalStorage(key, initial)** works like useState but remembers the value in localStorage (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) because localStorage only holds strings, and it wraps both in try/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, localStorage is in memory and resets on every Run.)

  3. 3

    **useDebounce(value, delay)** returns a copy of value that only updates after the value has stopped changing for delay milliseconds. Debouncing means waiting for a pause before acting, like a lift that waits until people stop walking in before closing its doors. Typing react fires five changes; the debounced value changes once, so you send one search request instead of five. Inside, an effect starts a setTimeout and its cleanup cancels the previous timer every time the value changes (Topic 7.3). Pattern: cleanup cancels stale work.

  4. 4

    **useOnlineStatus()** returns true or false from navigator.onLine and listens to the window's online and offline events. **useMediaQuery(query)** returns whether a CSS media query such as "(max-width: 600px)" or "(prefers-reduced-motion: reduce)" matches, using window.matchMedia and its change event. Both follow the pattern subscribe in an effect, unsubscribe in cleanup. Topic 10.3 rewrites both with useSyncExternalStore, which is the better tool for reading browser values.

  5. 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 returned ref.current during 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. 6

    **useInterval(callback, delay)** runs callback every delay milliseconds and stops when delay is null. A plain setInterval in 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 is useEffectEvent (Topic 7.9): the interval calls an effect event that always sees the latest props and state, so the interval only restarts when delay changes. Pattern: separate the reactive part (delay) from the non-reactive part (what to do on each tick).

Explain it without notes

01

How does useDebounce make sure only the last value is used?

02

Why does useLocalStorage read storage in a lazy initialiser and write in an effect?

03

Why is a naive useInterval either stale or constantly restarting, and how does useEffectEvent fix it?

04

What is the difference between the ref-based and the state-based usePrevious?

Practice

01

Write useCounter as a recipe: useCounter({ min = 0, max = 10 } = {}) returns { count, inc, dec } and never leaves the range.

02

Write useWindowWidth() that returns window.innerWidth and updates on resize, with cleanup.

03

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.

  • ↔

    localStorage is 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, useDebounce and useLocalStorage from memory.

  • I always clean up timers and listeners that a hook creates.

  • I can explain the stale-closure problem in useInterval and fix it with useEffectEvent.

  • I know why the state-based usePrevious is safer than the ref version.

  • I pass conditions into a hook (null delay) instead of calling it conditionally.