Skip to content
Guides3 min readUpdated

Writing custom hooks that survive production

The short answer

A good custom hook has a name starting with "use", returns either a tuple (for one or two values) or an object (for three or more), keeps every callback stable with useCallback or a ref, cleans up every subscription it creates, guards browser APIs for server rendering, and never accepts a config object it recreates internally. Extract a hook when the same stateful logic appears in a second component — not before.

A custom hook is just a function that calls other hooks. That simplicity is the point — and the reason a bad one spreads through a codebase before anyone notices.

1. Extract on the second use, not the first

Premature abstraction is worse than duplication. The first time you write the logic you do not yet know which parts vary. The second time you do, and the right shape becomes obvious.

2. Name it for what it gives you

The use prefix is not decorative — React's linter uses it to enforce the rules of hooks. Past that, name the hook after the value it returns rather than its implementation.

PreferAvoidWhy
useWindowSizeuseResizeHandlerNames the value, not the mechanism
useIsOnlineuseNetworkEffectReads like a boolean at the call site
useDebounceuseDebouncedValueWithTimeoutShorter, and the timeout is an implementation detail

3. Tuple for one or two values, object for three or more

A tuple lets the caller rename freely, which matters when a component uses the same hook twice. Past two elements, positional destructuring becomes a memory test.

1// Two values — a tuple lets both instances be named naturally.
2const [name, setName] = useLocalStorage('name', '');
3const [city, setCity] = useLocalStorage('city', '');
4
5// Five values — an object stays readable and lets callers take a subset.
6const { count, increment, decrement, reset, setCount } = useCounter(0);
When you return a tuple from TypeScript, add as const or an explicit tuple type. Without it TypeScript widens the return to an array union and destructuring loses its types.

4. Keep returned functions referentially stable

A function whose identity changes every render will invalidate every useMemo, useCallback and React.memo downstream, and re-run any effect that depends on it.

Stable actions
1export function useCounter(initial = 0) {
2  const [count, setCount] = useState(initial);
3
4  // The updater form means these never need `count` as a dependency,
5  // so their identity is stable for the life of the component.
6  const increment = useCallback(() => setCount((c) => c + 1), []);
7  const decrement = useCallback(() => setCount((c) => c - 1), []);
8  const reset = useCallback(() => setCount(initial), [initial]);
9
10  return { count, increment, decrement, reset };
11}

5. Clean up everything you start

Timers, listeners, observers, subscriptions and in-flight requests all need a cleanup function. React Strict Mode deliberately mounts, unmounts and remounts components in development specifically to expose the ones you forgot.

1useEffect(() => {
2  const observer = new ResizeObserver(handleResize);
3  observer.observe(element);
4  return () => observer.disconnect();   // ← not optional
5}, [element]);

6. Put the callback in a ref, not the dependency array

If your hook takes a callback, do not depend on it directly — callers pass inline arrow functions, which change identity every render and would re-subscribe your listener each time.

The latest-ref pattern
1export function useInterval(callback: () => void, delay: number | null) {
2  const saved = useRef(callback);
3
4  // Keep the ref current without disturbing the interval.
5  useEffect(() => {
6    saved.current = callback;
7  }, [callback]);
8
9  useEffect(() => {
10    if (delay === null) return;                    // null pauses the timer
11    const id = setInterval(() => saved.current(), delay);
12    return () => clearInterval(id);
13  }, [delay]);                                     // only the delay restarts it
14}
This is the single most important pattern in custom hooks. It gets its own page: fixing stale closures in React.

7. Accept primitives, not object literals

An options object passed inline is a new reference every render. If your hook depends on it, the effect never stops re-running.

1// ✗ New object every render → effect runs forever.
2useIntersectionObserver(ref, { threshold: 0.5 });
3
4// ✓ Destructure to primitives inside the hook and depend on those.
5export function useIntersectionObserver(ref, { threshold = 0, rootMargin = '0px' } = {}) {
6  useEffect(() => {
7    /* … */
8  }, [ref, threshold, rootMargin]);
9}

8. Make it work on the server

Even if you are not server rendering today, someone will move the app to Next.js eventually. Guard browser globals in the state initialiser and do the real read in an effect.

9. Type the generics, not the call sites

A well-typed hook means callers never write a type annotation. Infer from the arguments wherever you can.

1export function useLocalStorage<T>(key: string, initialValue: T) {
2  const [value, setValue] = useState<T>(/* … */);
3  const set = useCallback((v: T | ((prev: T) => T)) => { /* … */ }, [key]);
4  return [value, set] as const;         // as const preserves the tuple
5}
6
7// Inferred as string — no annotation needed at the call site.
8const [name, setName] = useLocalStorage('name', 'Ada');

10. Return loading and error, not just data

Any hook that does asynchronous work has at least three states. Returning only the happy path pushes the other two back onto every caller.

1const { data, loading, error } = useFetch<User[]>('/api/users');
2
3if (loading) return <Skeleton />;
4if (error) return <ErrorState error={error} />;
5return <UserList users={data} />;

11. Test the hook, not a component wrapping it

renderHook from React Testing Library runs a hook in isolation, which keeps the test about behaviour rather than markup.

counter.test.ts
1import { renderHook, act } from '@testing-library/react';
2import { useCounter } from '@danixsoft/hooks';
3
4it('clamps at the configured maximum', () => {
5  const { result } = renderHook(() => useCounter(0, { max: 2 }));
6
7  act(() => {
8    result.current.increment();
9    result.current.increment();
10    result.current.increment();   // should be refused
11  });
12
13  expect(result.current.count).toBe(2);
14});

12. Do one thing

A hook that fetches, caches, paginates and manages a modal is four hooks. Small hooks compose; large ones get copied and diverge.

Key takeaway

If you cannot describe what a hook returns in a single sentence, it is doing too much. Split it until you can.

Can a custom hook call another custom hook?

Yes, and it is encouraged. Composition is how hooks stay small. The only constraint is the rules of hooks: every call must be at the top level of the function, never inside a condition or loop.

Should every custom hook live in its own file?

Yes, in a hooks directory, named after the hook. It makes them discoverable, keeps imports honest about what a module depends on, and lets bundlers tree-shake properly.

When should I use useReducer inside a custom hook?

When several pieces of state change together in response to the same events. A fetch hook tracking data, loading and error is a good example — a reducer makes the invalid combinations unrepresentable.

Do custom hooks share state between components?

No. Each component that calls a hook gets its own independent state. To share state you need context, an external store, or a browser-level store like localStorage — which is exactly how useLocalStorage manages to stay in sync across components.

Keep reading