React

Designing a custom hook

A custom hook is a function that starts with use and returns only what callers need: behaviour, not markup, behind a signature that survives growth.

Key points

  • A custom hook encapsulates behaviour and returns data and actions, never markup.
  • The best signature takes one input, a defaulted delay and returns one value.
  • Past three arguments, an options object reads better.
  • Testing Library's renderHook exercises the hook without mounting any interface.

A custom hook is a function that starts with use, calls other hooks and returns only what the caller uses: when the name describes a behaviour and the signature hides the internals, the hook is well designed.

Behaviour, not interface

A hook draws nothing: it reads, computes, subscribes to events and returns data and actions. Markup stays in the component, the only place that decides where a button goes and what text it shows.

A signature that holds

import { useEffect, useState } from "react";

export function useDebouncedValue(value: T, delay = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);

  return debounced;
}

Three choices keep the signature stable: one input, a delay with a default, one return value. The caller cannot set the internal state, so the behaviour stays single and testable.

What to return

  • An object when there are three or more fields: names make the call site legible and order stops mattering.
  • A tuple when there are two, as useState does: you name the first, the second is the action.
  • Stable functions: when you return a callback used in a dependency array, wrap it in useCallback.

The mistakes you pay for

  • Returning JSX: the hook becomes a disguised component and cannot be tested without mounting the interface.
  • Nine parameters: past three arguments an options object reads better, otherwise the call site becomes unreadable.
  • Hiding a fetch: a hook that loads data without saying so in its name creates requests nobody connects to the page.

Testing the hook on its own

import { renderHook, act } from "@testing-library/react";
import { useDebouncedValue } from "./use-debounced-value";

test("returns the last value after the delay", () => {
  vi.useFakeTimers();
  const { result, rerender } = renderHook(({ v }) => useDebouncedValue(v), {
    initialProps: { v: "a" },
  });
  rerender({ v: "ab" });
  act(() => vi.advanceTimersByTime(300));
  expect(result.current).toBe("ab");
});

renderHook mounts the hook without markup: the test measures behaviour, not the DOM. act is needed so the timers run inside the React cycle, otherwise the assertion lands before the update.

The command

npm install --save-dev vitest @testing-library/react jsdom
npx vitest run

Vitest and Testing Library ship under the MIT licence; the test runs in jsdom, which simulates the DOM without opening a browser.

Live records in the catalogue

Three records show the same idea: use-image-upload wraps loading and preview, multi-step-form keeps the current step out of the individual fields, and the input category holds the fields those hooks drive.

The limitation

A custom hook shares no state between two callers: each component gets its own instance. Shared state needs a context or an external store; extraction removes duplicated code, it does not replace a state manager.

Where to continue

The rules that still apply are in rules of hooks; what can stay derived instead of stored is in derived state; the same idea in Vue sits in composables; the framework comparison is in Vue 3 vs React.

Frequently asked questions

What separates a custom hook from a plain function?

A custom hook may call other hooks, so it can use state and effects; a plain function cannot. The use prefix tells the linter and the reader.

Must a custom hook return state?

No. It can return a computed value, a function or an object with data and actions: what matters is that the signature describes the behaviour.

How many arguments should a custom hook take?

Technically as many as you like, but past three an options object reads better: the call site stays legible and a new field does not break the order.

Can I test a custom hook without a browser?

Yes, with renderHook from Testing Library and a jsdom environment. The test checks the returned value and the exposed functions, not markup.

Why should a hook not return JSX?

Because the hook becomes a component with a misleading name: React does not mount it as one, and the test has to rebuild the interface.