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
useMountthat 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.
// 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.
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.
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.
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
Stabilise the unstable hook
In the first example, wrap the return of
useGreeterUnstableinuseCallbackwith[name]. Predict the two console lines now, then run it. - 2
Fix useMount by not using it
Delete the
useMountcall fromChat. Nothing is lost:useChatRoomalready does the job correctly. Then rename the room twice (add a second button) and read the connect and leave lines. - 3
Put the options object in the deps
Change
useCountdownto takeoptionsand 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 changesleft, so you won't see a difference, but a parent that re-renders often would keep resetting the timer.)
Code & diagrams
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
Console
Nothing logged yet.Expected console output
after 3 unrelated renders -> unstable: 4 stable: 1
after a real change -> unstable: 5 stable: 2After switching, the screen says "music" but the `useMount` connection is still in "general". The purpose hook left general and joined music.
Preview
Console
Nothing logged yet.Expected console output
useMount: connected to general
useChatRoom: connected to general
useChatRoom: left general
useChatRoom: connected to musicNamed 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
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.
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) { ... }.
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
searchbeing stable (in effects ormemochildren), making it unstable later is a breaking change. Document which returned values are stable, as React does forsetStateanddispatch. - ▸
Return an object built with
useMemowhen callers destructure only some fields; return a tuple for two values. React's own hooks follow this:useStateanduseTransitionreturn tuples,useFormStatusreturns 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
Name hooks after their purpose. A name like
useChatRoom(roomId),useOnlineStatus()oruseCartTotal()tells the reader what the component gets. A name likeuseMount(fn),useUpdateEffect(fn)oruseEffectOnce(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
**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 thatfnusesroomId; whenroomIdchanges, 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 likeuseChatRoom(roomId)listsroomIdas a dependency inside, where the linter checks it, and re-syncs correctly. - 3
Return stable values. Callers often put what a hook returns into dependency arrays or pass it to
memochildren. 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 inuseCallbackand returned objects inuseMemo(Topic 16.4), with honest dependencies. Setters fromuseStateanddispatchfromuseReducerare 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
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 likeonDonethroughuseEffectEvent(Topic 7.9) so a new arrow function doesn't restart anything. - 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 ituseFormatPricemakes it follow the rules of hooks for no reason (it can't be called in a loop or anif). 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
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
roomIdreconnects"). And test hooks through a small component orrenderHook(Topic 17.6), the way a real caller uses them.
Explain it without notes
Why are hooks like useMount considered an anti-pattern?
What does it mean for a hook to return stable values, and why do callers care?
When should a hook take an options object, and how do you avoid dependency problems with it?
How do you decide whether to write a custom hook at all?
Practice
Rewrite useMount(() => { document.title = title; }) as a purpose hook that stays correct when title changes.
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.
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.