Back to React
React
medium
mid

How would you implement custom hooks to abstract reusable logic in React?

A custom hook is a function named `useXxx` that calls other hooks. Extract reusable stateful logic by composing built-ins: useState, useEffect, useRef, etc. Parameterize inputs, return a stable shape (`{ data, loading, error }` or `[value, setter]`), use useCallback for returned functions, document the contract. Examples: useDebounced, useFetch, useLocalStorage, useMediaQuery.

7 min read·~15 min to think through

Custom hooks are how you extract reusable stateful logic in React. Same rules as built-ins, no special API.

Anatomy

A custom hook:

  • Is a function.
  • Name starts with use.
  • Calls other hooks (built-in or custom).
  • Returns whatever the caller needs (a value, an array, an object).

That's it. No special registration.

Example 1: useToggle

tsx
function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn(o => !o), []);
  const set = useCallback((v: boolean) => setOn(v), []);
  return [on, { toggle, set }] as const;
}

// usage
const [open, { toggle }] = useToggle();

Example 2: useDebounced

tsx
function useDebounced<T>(value: T, delay = 300): T {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}

Example 3: useLocalStorage

tsx
function useLocalStorage<T>(key: string, initial: T) {
  const [val, setVal] = useState<T>(() => {
    if (typeof window === 'undefined') return initial;
    const raw = localStorage.getItem(key);
    return raw ? JSON.parse(raw) : initial;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(val));
  }, [key, val]);

  return [val, setVal] as const;
}

Example 4: useFetch

tsx
function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const ctrl = new AbortController();
    setLoading(true);
    fetch(url, { signal: ctrl.signal })
      .then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); })
      .then(d => { setData(d); setError(null); })
      .catch(e => { if (e.name !== 'AbortError') setError(e); })
      .finally(() => setLoading(false));
    return () => ctrl.abort();
  }, [url]);

  return { data, loading, error };
}

For production, use React Query instead.

Example 5: useMediaQuery

tsx
function useMediaQuery(query: string) {
  const [matches, setMatches] = useState(() => {
    if (typeof window === 'undefined') return false;
    return window.matchMedia(query).matches;
  });

  useEffect(() => {
    const mql = window.matchMedia(query);
    const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
    mql.addEventListener('change', handler);
    return () => mql.removeEventListener('change', handler);
  }, [query]);

  return matches;
}

const isMobile = useMediaQuery('(max-width: 768px)');

Design principles

  1. Parameterize inputs. Don't hard-code URLs or keys inside.
  2. Return a stable shape. Predictable destructuring: { data, loading, error } or [value, setter].
  3. Wrap returned functions in useCallback so consumers can put them in deps without infinite loops.
  4. Side effects with cleanup. Subscriptions, listeners, timers — always return a cleanup.
  5. No UI. Hooks return data and callbacks, never JSX.
  6. Generic for flexibility. TypeScript generics on inputs and outputs.
  7. One responsibility. Don't bundle unrelated concerns.

Anti-patterns

  • Hard-coding the URL or key: kills reusability.
  • Returning unstable references: every render the consumer sees new functions/objects, breaks downstream memoization.
  • God hook: bundles data fetching + UI state + URL parsing.
  • Hidden context dep: hook reads from a Context, so it only works under a specific provider — document loudly.

Testing

tsx
import { renderHook, act } from '@testing-library/react';

test('useToggle', () => {
  const { result } = renderHook(() => useToggle());
  act(() => result.current[1].toggle());
  expect(result.current[0]).toBe(true);
});

When NOT to extract

If the logic isn't reused, inline it. Premature hook extraction is a real cost — abstractions that fit one caller often don't fit two.

Senior framing

Custom hooks are how React stays composable. Each hook is a contract — parameterize inputs, return a stable shape, encapsulate side-effects with cleanup. Write hooks like small libraries; they survive reuse without surprises.

Follow-up questions

  • Why do returned functions in custom hooks need useCallback?
  • When would you split a custom hook into smaller hooks?
  • How do you test a hook with renderHook?

Common mistakes

  • Hard-coding URLs/keys inside the hook.
  • Returning fresh objects/functions each render — breaks downstream memoization.
  • Bundling unrelated concerns in one mega-hook.

Performance considerations

  • Custom hooks compile to plain function calls — no overhead beyond the built-ins they invoke. The cost model matches inlining the logic.

Edge cases

  • Object/array params must be memoized by the caller or the hook's effects re-run forever.
  • SSR-safe hooks must guard window/localStorage access.
  • StrictMode double-mount means setup/teardown must be idempotent.

Real-world examples

  • react-use, usehooks.com, every library exports custom hooks: TanStack Query (useQuery), React Hook Form (useForm), Zustand (useStore), Mantine, Chakra UI.

Senior engineer discussion

Senior framing: custom hooks are public APIs. Naming, shape stability, error model, and observability matter. The same discipline that makes a good component makes a good hook — parameterize, return stable, encapsulate, document.

Related questions