Command Palette

Search for a command to run...

Hectal
PHASE 10Intermediate ~29 min· topic 6 of 6

Topic 10.6

Designing Good Hooks

In one line

A good hook is named after what it does (useChatRoom, not useMount), takes clear inputs (an options object when there are several), returns stable values and functions so callers' effects don't re-run, and exists only when it actually uses hooks and hides real complexity.

Think of it like this

A TV remote. A good remote has buttons named after what you want (Volume up, Netflix), not after how the electronics work (Send infrared code 0x2F). It gives the same result every time you press a button, and it doesn't have a button for something you could do with your hand (like picking up the remote). A good hook is the same: named after its purpose, predictable, and only made when it saves real work.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Purpose-named hook
A hook named after its job, like useOnlineStatus, not after when it runs.
Lifecycle hook
A wrapper like useMount that only runs code at a moment (mount, update), hiding effect dependencies.
Stable value
A value (often a function or object) that stays the same object between renders until its inputs change.
Options object
One object argument with named settings and defaults: useCountdown({ from: 5 }).
useCallback
A hook that returns the same function between renders until its dependencies change (Topic 16.4).
useMemo
A hook that returns the same calculated value between renders until its dependencies change (Topic 16.4).
React Compiler
A build tool that adds memoisation to components and hooks automatically (Topic 16.5).

Step by step

01Rename lifecycle hooks into purpose hooks

useMount says when; useChatRoom says what. The purpose hook also owns the dependency list, so the linter can check it.

ChatRoom.jsxwhole filejsx
// Avoid: the dependency on roomId is hidden and missing
useMount(() => {
  const conn = connect(roomId);
  return () => conn.close();
});

// Prefer: the purpose is in the name, the deps are checked inside
function useChatRoom(roomId) {
  useEffect(() => {
    const conn = connect(roomId);
    return () => conn.close();
  }, [roomId]);
}

02Make returned functions and objects stable

A caller may write useEffect(() => { search(); }, [search]). If search is new every render, that effect runs every render. useCallback keeps it the same until its real inputs change.

useSearch.jswhole filejsx
function useSearch(source) {
  const [query, setQuery] = useState("");
  const search = useCallback(() => source.find(query), [source, query]);
  // the returned object is stable too, so it can be a dependency
  return useMemo(() => ({ query, setQuery, search }), [query, search]);
}

03Take an options object with defaults

Destructure in the parameter list with defaults, and = {} so calling with no argument works. Depend on the primitives, not the object.

useCountdown.jswhole filejsx
function useCountdown({ from = 10, interval = 1000, onDone } = {}) {
  const [left, setLeft] = useState(from);
  const done = useEffectEvent(() => onDone?.());
  useEffect(() => {
    if (left === 0) { done(); return; }
    const id = setTimeout(() => setLeft(left - 1), interval);
    return () => clearTimeout(id);
  }, [left, interval]); // primitives only
  return left;
}

04Decide: hook, plain function, or nothing

Ask three questions before you write use.

Decide: hook, plain function, or nothingdiagram
Rendering diagram…

05Shape the return value for the caller

One value: return it directly. Two values the caller will rename: an array ([on, toggle]). Three or more: an object. Return actions (addItem), not raw setters, when the hook has rules to protect (a cart that can't go below zero).

Try it yourself

  1. 1

    Stabilise the unstable hook

    In the first example, wrap the return of useGreeterUnstable in useCallback with [name]. Predict the two console lines now, then run it.

  2. 2

    Fix useMount by not using it

    Delete the useMount call from Chat. Nothing is lost: useChatRoom already does the job correctly. Then rename the room twice (add a second button) and read the connect and leave lines.

  3. 3

    Put the options object in the deps

    Change useCountdown to take options and use [left, options] as dependencies. Does anything go wrong? (Each render passes a new object, so the timer is cleared and restarted every render. Here each render also changes left, so you won't see a difference, but a parent that re-renders often would keep resetting the timer.)

Code & diagrams

Unstable return values re-run the caller's effects New tab

The effect that depends on the unstable function runs on every render. The stable one runs once at mount and once when `name` really changes.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

after 3 unrelated renders -> unstable: 4 stable: 1
after a real change -> unstable: 5 stable: 2
useMount hides a bug that useChatRoom avoids New tab

After switching, the screen says "music" but the `useMount` connection is still in "general". The purpose hook left general and joined music.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

useMount: connected to general
useChatRoom: connected to general
useChatRoom: left general
useChatRoom: connected to music
An options object with defaults React 19.2+ New tab

Named options are easy to read and extend. Depending on `left` and `interval` (primitives) and reading `onDone` through `useEffectEvent` means the inline object and arrow function never restart the timer.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

render: 3
render: 2
render: 1
render: 0
Lift-off!

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

Hide an empty dependency list inside useMount

Run the second live example: Chat uses useMount to connect to roomId, then the room switches to music.

terminal
$ Console
── what you'll see ──
useMount: connected to general
useChatRoom: connected to general
useChatRoom: left general
useChatRoom: connected to music

Break #2

Call a non-hook function with the use prefix inside a condition

Rename formatPrice to useFormatPrice (it calls no hooks) and call it inside if (showPrice) { ... }.

terminal
$ npx eslint src/
── what you'll see ──
7:12 error React Hook "useFormatPrice" is called conditionally. React Hooks must be called in the exact same order in every component render react-hooks/rules-of-hooks

Myth vs fact

Myth

useMount and useUpdateEffect make code clearer.

Fact

They hide the dependency list from the linter and frame effects as lifecycle events, which is the exact mindset that causes stale-connection bugs. Name the hook after what it synchronises.

Myth

Every helper used by components should be a hook.

Fact

Only functions that call hooks are hooks. Formatting, validation and sorting are plain functions, which are easier to test and can be called anywhere.

Myth

With the React Compiler, stability no longer matters.

Fact

The compiler memoises code it compiles, but libraries are used by apps that may not run the compiler. Stable return values are part of a hook's contract.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Stable identity is a contract: once callers rely on search being stable (in effects or memo children), making it unstable later is a breaking change. Document which returned values are stable, as React does for setState and dispatch.

  • ▸

    Return an object built with useMemo when callers destructure only some fields; return a tuple for two values. React's own hooks follow this: useState and useTransition return tuples, useFormStatus returns an object.

  • ▸

    For callback options (onDone, onMessage), useEffectEvent (React 19.2) is now the idiomatic way to call the latest callback without making it a dependency. Before 19.2, libraries stored it in a ref updated in a layout effect.

  • ▸

    Design for StrictMode: a good hook's effects survive mount, cleanup, mount (Topic 7.4). If a hook needs "only once per app" behaviour (analytics init), it belongs at module level or in the app's entry point, not in a useMount.

Remember this

  1. 1

    Name hooks after their purpose. A name like useChatRoom(roomId), useOnlineStatus() or useCartTotal() tells the reader what the component gets. A name like useMount(fn), useUpdateEffect(fn) or useEffectOnce(fn) only re-labels a lifecycle moment and tells you nothing about the job. Good test: could a product person guess what the hook does from its name? If the name is about when code runs rather than what it does, rethink it.

  2. 2

    **Avoid useMount-style lifecycle hooks.** function useMount(fn) { useEffect(() => { fn(); }, []); } hides an empty dependency list. The linter can't see inside it, so it can't warn that fn uses roomId; when roomId changes, nothing reconnects and the component silently talks to the old room. Effects are about synchronising with something (Topic 7.5), not about mount and unmount. A purpose hook like useChatRoom(roomId) lists roomId as a dependency inside, where the linter checks it, and re-syncs correctly.

  3. 3

    Return stable values. Callers often put what a hook returns into dependency arrays or pass it to memo children. If the hook returns a new function or object every render, every caller's effect re-runs and every memoised child re-renders, even when nothing changed. Wrap returned functions in useCallback and returned objects in useMemo (Topic 16.4), with honest dependencies. Setters from useState and dispatch from useReducer are already stable. With the React Compiler (Topic 16.5) this memoisation happens automatically, but library hooks should still be stable for callers who don't use it.

  4. 4

    Use an options object when there are several inputs. useFetch(url, true, 3, 500) is unreadable; useFetch(url, { retry: 3, retryDelay: 500 }) names every value and lets you add options later without breaking callers. Put the main required input first, then the options object with defaults: function useCountdown({ from = 10, interval = 1000, onDone } = {}). Callers write the object inline, so it is new every render: never put the whole options object in a dependency array. Destructure it and depend on the primitive values (from, interval), and read callbacks like onDone through useEffectEvent (Topic 7.9) so a new arrow function doesn't restart anything.

  5. 5

    When not to make a hook. If the function calls no hooks, it's a plain function: formatPrice(n), isValidEmail(s), sortByDate(list). Naming it useFormatPrice makes it follow the rules of hooks for no reason (it can't be called in a loop or an if). Don't wrap a single built-in hook just to rename it (useBoolean = useState). Don't make one giant hook (useEverything) that returns 15 values for one screen; split by concern. Extract a hook when logic repeats, or when it's complex enough that a name makes the component clearer.

  6. 6

    Keep hooks focused and honest. One concern per hook, like one concern per effect. Let the caller own decisions: return state and actions instead of rendering UI or showing toasts inside. Document what is reactive ("changing roomId reconnects"). And test hooks through a small component or renderHook (Topic 17.6), the way a real caller uses them.

Explain it without notes

01

Why are hooks like useMount considered an anti-pattern?

02

What does it mean for a hook to return stable values, and why do callers care?

03

When should a hook take an options object, and how do you avoid dependency problems with it?

04

How do you decide whether to write a custom hook at all?

Practice

01

Rewrite useMount(() => { document.title = title; }) as a purpose hook that stays correct when title changes.

02

This hook returns a new object every render: function useCart() { const [items, setItems] = useState([]); return { items, add: (i) => setItems((x) => [...x, i]) }; }. Make its return value stable.

03

Turn useFilter(list, "name", true, "asc") into an options-object API and write the hook.

Trade-offs

  • ↔

    Memoising every returned value adds code and small costs; for app-internal hooks used in one place, plain returns are fine until a caller needs stability (or the React Compiler handles it).

  • ↔

    Options objects are flexible but hide which options are reactive. Document it, and keep the required input as a separate first argument.

  • ↔

    A hook that hides a lot (fetching, caching, retries) is convenient but harder to debug; expose status values (isLoading, error) rather than swallowing them.

Done when you can

  • I name hooks after what they do and avoid useMount-style wrappers.

  • I return stable functions and objects from shared hooks.

  • I use an options object with defaults for hooks with several inputs.

  • I never put a whole options object in a dependency array.

  • I write plain functions when no hook is called.

  • I can decide when a custom hook is worth creating.