Skip to content

Custom hooks in practice

The Hooks chapter introduced the idea: a custom hook is a function that starts with use and composes hooks into reusable logic. This chapter is about what that looks like in practice, and the course makes the point dramatically: after a whole section spent building a headless Toggle component, a custom hook replaces it in a fraction of the code, and the component gets deleted.

The line worth drawing precisely is against an ordinary utility function. A function that fetches data or formats a date is a utility. A custom hook calls other hooks, which is why the rules of hooks apply inside it.

Extracting an effect: useEffectOnUpdate

The Toggle component ended up with a chunk of logic whose job was "run this callback when the state changes, but never on the first render": a useEffect guarded by a firstRender ref. That's not toggle logic; it's effect logic, useful anywhere. Extracting it produces a hook with the same signature as useEffect itself:

jsx
export default function useEffectOnUpdate(effectFunction, deps) {
  const firstRender = useRef(true)

  useEffect(() => {
    if (firstRender.current) {
      firstRender.current = false
    } else {
      effectFunction()
    }
  }, deps)
}

Callers write useEffectOnUpdate(onToggle, [on]) and the first-render bookkeeping disappears from their code. Hooks that wrap other hooks this way are sometimes worth building even when the wrapped logic is a few lines, because the name carries the intent.

The guard has a development-only wrinkle

The same one render props flagged: StrictMode spends the ref on its first effect run, so the callback fires on the second. Production builds do not do this, and the durable fix is to compare the value against the previous one rather than track whether this is the first render.

Extracting state: useToggle

useEffectOnUpdate returns nothing, like useEffect. Most custom hooks give something back, like useState does, and the shape of what they return is a design decision. useToggle gathers the boolean, the flip function, and the change callback into one place:

jsx
export default function useToggle({ initialValue = false, onToggle = () => {} } = {}) {
  const [on, setOn] = useState(initialValue)
  const toggle = () => setOn(prevOn => !prevOn)

  useEffectOnUpdate(onToggle, [on])

  return [on, toggle]
}
jsx
const [on, toggle] = useToggle({ initialValue: true })

Each call to useToggle gets its own useState, so two callers hold two independent booleans, whether that's two components or one component calling the hook twice.

Three decisions in there, each a pattern you'll reuse:

  • Return an array when the values are a natural pair the caller will rename, exactly like useState: one caller destructures [on, toggle], another [open, toggleOpen]. Return an object when there are several values and names matter more than order.
  • Take a configuration object instead of positional parameters. With useToggle(false, callback) a caller who only wants the callback is forced to supply the boolean first; with { initialValue, onToggle } they pass what they mean and skip the rest, and every option can carry a default.
  • Default the callback to a noop (() => {}) so the hook can call onToggle() unconditionally without crashing callers who never passed one.

The payoff: deleting the component

With useToggle in hand, the star widget stops needing any Toggle machinery at all:

jsx
export default function Star({ onChange }) {
  const [on, toggle] = useToggle({ onToggle: onChange })

  return on
    ? <BsStarFill className="star filled" onClick={toggle} />
    : <BsStar className="star" onClick={toggle} />
}

The menu keeps its compound component shape, because its pieces still need shared implicit state, but its provider now gets its values from the hook: Menu calls useToggle, passes { open, toggleOpen } through its own context, and the headless Toggle component's files get deleted.

That deletion is the section's real lesson. The headless component earned its keep while many compound components might have shared it; once the logic fit in a hook, the hook was the simpler tool, and ripping out working code you were proud of is a normal part of the job.

The two patterns are complements: a hook shares logic, a headless component shares logic through the tree via context and composition. Reach for the hook first, and let a component earn the extra ceremony. The course caps this section with a solo project, Component Library++, which puts every pattern from these five chapters into one library.

A habit worth forming early: before writing a custom hook, check whether the community already has. Debounced values, media queries, local storage state, event listeners, previous-value tracking, all of these exist as proven hooks in small libraries, and reading their source is a fast way to absorb the idioms. Writing your own still wins when the logic is specific to your app, and app-specific hooks like useCurrentUser or useCartTotal are often the cleanest layer an app's data logic can live in: components stay declarative, and the messy parts stay in one named, testable function.

Custom hooks share logic; they do not share state. When two callers must see the same value, the hook alone can't do it: the state has to live once, in a provider, and the hook becomes the reader, the useTheme pattern from the context chapter. Keeping "reusable logic" and "shared state" distinct in your head prevents the classic mistake of expecting a hook to act like a store.

Two details decide whether hooks like these are pleasant to ship. The first is identity: useToggle builds a fresh toggle function on every render, so a caller who passes it to a memoized child or into another hook's dependency array hands over a new value each time. Wrap it in useCallback inside the hook when callers depend on a stable reference.

The second is tooling. Forwarding a deps array through a wrapper hook works at runtime as long as its length is constant, but exhaustive-deps cannot verify an array it did not see written out, and it ignores useEffectOnUpdate(onToggle, [on]) at the call site entirely unless the project lists the hook in the rule's additionalHooks option. A codebase that leans on wrapper hooks should add that setting, so the warnings the effects chapter told you to treat as real bugs keep reaching you.

JunoBottle the logic, reuse it anywhere A custom hook is a function whose name starts with use and that builds on hooks like useState to package up a piece of logic. Once useToggle exists, any component can get an on-off value and a flip function in one line, the same way useState hands you a value and a setter.

Each component that calls it gets its own copy of the state, so a hook is a recipe you reuse rather than a value everyone shares.

JunoBottle the logic, reuse it anywhere Extract repeated stateful logic into hooks: useEffectOnUpdate for effects that skip the first render, useToggle for boolean state with an onToggle callback.

Return an array when callers will rename a natural pair, take a config object with defaults instead of positional parameters, and default callbacks to a noop.

Prefer a hook over a headless component until the logic has to travel through the tree.

JunoBottle the logic, reuse it anywhere A custom hook composes hooks, inherits their rules transitively, and shares logic while every caller keeps independent state; pair it with a provider when state must be shared rather than duplicated.

Two details decide whether one is pleasant to ship: the functions it returns are rebuilt on every render, so wrap them in useCallback when callers pass them to memoized children or into dependency arrays, and a wrapper hook that forwards a deps array stays invisible to exhaustive-deps until the project names it in the rule's additionalHooks option.

Next up: Routing, where the URL starts deciding which components render.