Back to React
React
medium
mid

How would you make a custom React hook reusable across many different components?

Design custom hooks like small libraries: parameterize all inputs, return a stable shape ({ data, loading, error, refetch }), keep side-effects (subscriptions, listeners) inside with cleanup, avoid hard-coding UI concerns or context dependencies, document the contract, and write tests with @testing-library/react's renderHook.

6 min read·~15 min to think through

A reusable hook is just an API — design it like one.

Principles

  1. Parameterize inputs — don't read URL params or context internally if the hook should also work elsewhere.
  2. Return a stable shape — predictable destructuring: { data, loading, error, refetch } or [value, setValue].
  3. Encapsulate side-effects — set up listeners/subscriptions in useEffect with proper cleanup.
  4. Stable identities — wrap returned functions in useCallback so consumers can put them in deps without infinite loops.
  5. No UI — hooks return data and callbacks, never JSX.
  6. Generics over types — use TypeScript generics for shape flexibility.

Example: useFetch

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

  const refetch = useCallback(async () => {
    setLoading(true);
    try {
      const res = await fetch(url);
      if (!res.ok) throw new Error(res.statusText);
      setData(await res.json());
      setError(null);
    } catch (e) {
      setError(e as Error);
    } finally {
      setLoading(false);
    }
  }, [url]);

  useEffect(() => {
    let cancelled = false;
    refetch().catch(() => {
      if (cancelled) return;
    });
    return () => { cancelled = true; };
  }, [refetch]);

  return { data, loading, error, refetch };
}

Consumer:

tsx
const { data, loading, error, refetch } = useFetch<User>('/api/user');

Example: useLocalStorage (parameterized key)

tsx
export function useLocalStorage<T>(key: string, initial: T) {
  const [val, setVal] = useState<T>(() => {
    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;
}

Composability

Hooks should compose. Build small ones and combine:

tsx
function useDebounced<T>(value: T, ms = 300) { ... }
function useSearch() {
  const [q, setQ] = useState('');
  const debounced = useDebounced(q);
  const { data } = useFetch(`/search?q=${debounced}`);
  return { q, setQ, results: data };
}

Tests

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

test('counter increments', () => {
  const { result } = renderHook(() => useCounter());
  act(() => result.current.inc());
  expect(result.current.count).toBe(1);
});

Anti-patterns

  • Reading context inside the hook 'for convenience' — couples it to a provider tree.
  • Returning unstable functions that change every render — breaks downstream useEffect deps.
  • Doing imperative DOM work without a ref the caller passes in.
  • Reaching for global state when a parameter would do.

Follow-up questions

  • How do you avoid stale closures inside a custom hook?
  • When should a hook return useCallback-wrapped functions?
  • How do you test a hook that uses useEffect?

Common mistakes

  • Hardcoding URLs or keys inside the hook — kills reusability.
  • Returning a fresh object/function each render that consumers put in deps — infinite loop.
  • Bundling unrelated concerns (data + UI + URL parsing) into one mega-hook.

Performance considerations

  • Hooks themselves are cheap. The cost comes from triggering effects every render due to unstable deps. Audit returned identities (useCallback/useMemo where needed) and make heavy work conditional on real changes.

Edge cases

  • If the parameter is an object/array, the consumer must memoize it 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, TanStack Query (useQuery is a public-API hook), React Hook Form (useForm). Each parameterizes inputs, returns stable shapes, and stays UI-agnostic.

Senior engineer discussion

Senior signal: thinking of hooks as a public API for a component. Naming, shape stability, error model, and observability matter. The same discipline that makes a good React component or function makes a good hook.

Related questions