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
useStatedoes: 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.