ESC

Type to search the knowledge base.

Custom Hooks Design

Extract reusable stateful logic with use* hooks — API design, composition, testing seams, and footguns that recreate render bugs.

intermediate5 min read
  • react
  • hooks
  • custom-hooks
  • architecture

A custom hook is a function whose name starts with use and that calls other hooks. It lets you share stateful logic — not JSX — across components without HOCs or render props.

function useOnlineStatus() {
  const [online, setOnline] = useState(true);
  // effects…
  return online;
}

Good custom hooks feel like language features of your app: useUser(), useDebouncedValue(value, 300), useFetch(url). Bad ones hide effects, return mystery tuples, or break the rules of hooks by calling hooks conditionally inside.

Official guide: Reusing Logic with Custom Hooks.

The problem they solve

Two components need the same subscription + state machine:

// Duplicated in Header and StatusBadge:
useEffect(() => {
  const on = () => setOnline(true);
  // …
}, []);

Extract once:

function useOnlineStatus() {
  const [online, setOnline] = useState(
    () => (typeof navigator !== 'undefined' ? navigator.onLine : true)
  );

  useEffect(() => {
    const on = () => setOnline(true);
    const off = () => setOnline(false);
    window.addEventListener('online', on);
    window.addEventListener('offline', off);
    return () => {
      window.removeEventListener('online', on);
      window.removeEventListener('offline', off);
    };
  }, []);

  return online;
}

function Header() {
  const online = useOnlineStatus();
  return <span>{online ? 'Connected' : 'Offline'}</span>;
}

Each component call gets its own state and effects — hooks are not singletons. Shared global state still needs context or an external store.

Design rules that hold up

1. Name with use and a verb/noun that states the product concept

  • useAuth, useMediaQuery('(min-width: 800px)'), useLocalStorage(key, initial)
  • Avoid useUtils, useStuff, useManager

2. One hook, one job

Split useDashboardDataAndThemeAndWebSocket into focused hooks. Compose them in the component or in a higher-level hook that orchestrates clearly.

3. Return a stable, obvious API

Style Example Use when
Single value const online = useOnline() One primary result
Object const { data, error, retry } = useQuery(…) Named fields, optional bits
Tuple const [value, setValue] = useToggle(false) useState-like symmetry

Objects are friendlier when returning more than two things. Document whether the object identity is stable (useMemo) if consumers depend on referential equality.

4. Accept only what the hook needs

// Prefer
useUser(userId);
// over dumping the whole props bag
useUser(props);

Narrow inputs make the hook testable and tree-shake reasoning easier.

5. Own cleanup

If you subscribe, connect, or set timers, return cleanup from useEffect inside the hook so every consumer gets it free. See useEffect Fundamentals.

Composition example: debounce + fetch

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

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

  return debounced;
}

function useSearchResults(query) {
  const debounced = useDebouncedValue(query, 300);
  const [state, setState] = useState({ status: 'idle', data: [], error: null });

  useEffect(() => {
    if (!debounced) {
      setState({ status: 'idle', data: [], error: null });
      return undefined;
    }

    const controller = new AbortController();
    setState((s) => ({ ...s, status: 'loading', error: null }));

    fetch(`/api/search?q=${encodeURIComponent(debounced)}`, {
      signal: controller.signal,
    })
      .then(async (res) => {
        if (!res.ok) throw new Error(String(res.status));
        return res.json();
      })
      .then((data) => setState({ status: 'success', data, error: null }))
      .catch((err) => {
        if (err.name === 'AbortError') return;
        setState({ status: 'error', data: [], error: err });
      });

    return () => controller.abort();
  }, [debounced]);

  return state;
}

Building blocks stay small; the product hook tells a story. Abort ties to Fetch API Fundamentals.

Context-backed hooks

const AuthContext = createContext(null);

export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth requires AuthProvider');
  return ctx;
}

This is the standard way to package context: private context module + public hook.

What custom hooks are not

  • Not a way to share JSX structure — use components for UI.
  • Not automatically shared state — two useOnlineStatus() calls are two states (unless you read one context inside).
  • Not exempt from rules of hooks — no conditional useX() inside.
  • Not dependency-injection containers for every service in the app — keep the surface human.

Testing seams

Custom hooks test well with small React test renderers or by testing a tiny harness component:

function Harness() {
  const value = useDebouncedValue('a', 100);
  return <output>{value}</output>;
}

Alternatively extract pure helpers from the hook (function nextState(state, action)) and unit test those without DOM. Reducers pair well (useReducer).

API footguns and better shapes

// Hard to evolve
return [data, error, loading, refetch, cancel, lastUpdated];

// Clearer
return { data, error, loading, refetch, cancel, lastUpdated };
// Surprising: sometimes number, sometimes object
return enabled ? count : { count, setCount };

// Stable shape always
return { count, setCount, enabled };

Document whether returned functions are stable (useCallback) so memoized children don’t thrash — useMemo and useCallback.

Conditional logic: branch inside, not around

// ❌
if (id) {
  useResource(id);
}

// ✅
useResource(id); // hook no-ops or disables effect when !id
function useResource(id) {
  useEffect(() => {
    if (!id) return undefined;
    // …
  }, [id]);
}

Same structural rule as components: hooks always called, behavior branches.

Footguns

  1. HOC flashbacks — wrapping every component in ten hooks with unclear order. Prefer a few deep hooks.
  2. Silent shared mutable refs across hook calls without documentation.
  3. Fetching in hooks without abort — race conditions when inputs change.
  4. Returning new object literals every render without need, breaking memo children.
  5. Putting the hook in a condition after a refactor.
  6. Giant “god hooks” that import half the app — hard to test and tree-shake.

Interview angle

Prompt: “What are custom hooks? When do you extract one?”

Strong answer: “A custom hook is a use* function that composes other hooks to reuse stateful logic across components. I extract when two components share subscription, timing, or fetch patterns — not for sharing markup. Each call has independent state unless the hook reads shared context. I keep hooks pure in structure (always called), clean up effects, and return a clear value or object API.”

Live task: write useLocalStorage(key, initial) with SSR safety (typeof window) and storage event sync as a stretch goal.

Further reading

Related guides