Custom hooks

Extract stateful logic into a function that reads like a sentence, and follow the two rules that make hooks work.

17of 20
≈ 12Min read
8Parts

A custom hook is just a function

No new concept, no API to learn: a function whose name starts with use and which calls hooks. The name is what tells React (and the linter) to apply the rules to it.

that happens to call other hooks
function useToggle(initial = false) {
  const [on, setOn] = React.useState(initial);
  const toggle = React.useCallback(() => setOn((v) => !v), []);
  return [on, toggle];
}

function Panel() {
  const [open, toggle] = useToggle();
  return <button onClick={toggle}>{open ? "Close" : "Open"}</button>;
}

It shares logic, not state

The most common misunderstanding

Two components calling useToggle() get two independent pieces of state. A hook is a recipe, not a store. If two components must share one value, lift it up or put it in a context.

The two rules

State lives in a list attached to the component instance. React walks that list in order on every render, which is why the order has to be identical every time.

  • Call hooks at the top level. Never inside an if, a loop, a nested function or after an early return.
  • Call them only from a component or another hook. Not from an ordinary function, not from an event handler.
why the first rule exists
// React identifies each hook by its CALL ORDER, not by name
if (user) {
  const [name, setName] = useState("");   // sometimes the 1st hook, sometimes not
}
const [age, setAge] = useState(0);        // now reads the wrong slot

// the fix is always the same: move the condition inside
const [name, setName] = useState("");
if (user) { … }

What to return

an array for two, an object for more
// an array, so the caller can name them freely — like useState
const [open, toggle] = useToggle();
const [value, setValue] = useLocalStorage("key", "");

// an object, once there are three or more
const { count, increment, decrement, reset } = useCounter(0);
const { data, error, isPending } = useDoors(query);

What is worth extracting

Not worth extracting: a single useState with a nice name, or a wrapper that only renames arguments. A hook with five options and three return values is usually two hooks.

  • Logic used in more than one component — a media query, a debounce, a subscription.
  • A block of hooks that has one job and a name: useOnlineStatus, useClickOutside, useInterval.
  • Anything that makes a component hard to read — an effect with a subscription and cleanup, sitting in the middle of markup.

A hook with an effect

The cleanup is the entire mechanism: each render cancels the previous timer, so the state is only set once the value has stopped changing.

debounce, the whole thing
function useDebounced(value, delay = 300) {
  const [debounced, setDebounced] = React.useState(value);

  React.useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);     // a new keystroke cancels the pending one
  }, [value, delay]);

  return debounced;
}

const query = useDebounced(text, 300);   // only settles when typing stops

Naming

A good hook name says what the component gets, not when something happens. If the only honest name is useEffectStuff, the code probably belongs where it is.

NameReads as
useOnlineStatus()a value you are given
useClickOutside(ref, fn)a behaviour you attach
useDoors(query)data for this component
useMount(fn)avoid — it describes a lifecycle, not a purpose

Before writing one

React ships useId for stable ids, useSyncExternalStore for subscribing to anything outside React, useDeferredValue and useTransition for keeping an interface responsive. Libraries ship the rest. Check first — useDebounce has been written ten thousand times.

Practice