RoadmapDay 24 / 80
React (Web)Month 2 · Week 5

Day 24: Custom Hooks Design Patterns & Composition

Design custom hooks that compose cleanly — extracting stateful logic without leaking implementation details or breaking the Rules of Hooks.

Mark this day complete

Study

Concepts

A custom hook is just a function that calls other hooks

There is no special hook-registration mechanism — a "custom hook" is purely a naming convention (`useXxx`) plus the Rules of Hooks: only call hooks at the top level (never inside conditionals/loops/nested functions) and only from React function components or other hooks. React relies on hooks being called in the EXACT SAME ORDER on every render to match each `useState`/`useRef`/etc. call to its stored slot internally — breaking that order (e.g. an early return before a hook call) corrupts state across unrelated hooks.

Good custom hooks return the smallest, most stable API needed by callers — often a tuple like `[value, setValue]` (mirroring useState) or an object with a few well-named fields — and hide internal details (which hooks compose it, how many state variables it uses) completely, so the hook can be refactored internally without breaking callers.

Composition over configuration

Instead of building one large hook with many boolean flags/options, prefer composing several small, focused hooks and letting the calling component wire them together — this mirrors composing small components instead of building one component with dozens of props. `useFetch` + `useDebounce` + `useLocalStorage` composed together is more flexible and testable than a single `useSearchWithCachingAndDebounce` mega-hook.

See It

Visualizations

Visualization

Composing small hooks into a feature

Each hook owns one concern; the component wires them together.

useProductSearch(query) — component-facing hook
useDebouncedValue(query, 300)
useFetch(`/api/search?q=${debounced}`)
useLocalStorage("recentSearches")

Build It

Code Examples

A focused, reusable useDebouncedValue hook

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

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(id); // cancel the stale timeout if value changes again
  }, [value, delayMs]);

  return debounced;
}

Composing three small hooks into one feature hook

jsx
function useFetch(url) {
  const [state, setState] = useState({ data: null, loading: true, error: null });

  useEffect(() => {
    let cancelled = false;
    setState({ data: null, loading: true, error: null });

    fetch(url)
      .then((res) => res.json())
      .then((data) => !cancelled && setState({ data, loading: false, error: null }))
      .catch((error) => !cancelled && setState({ data: null, loading: false, error }));

    return () => { cancelled = true; }; // ignore late responses for stale urls
  }, [url]);

  return state;
}

// The feature hook composes both — callers never see the internals
function useProductSearch(query) {
  const debouncedQuery = useDebouncedValue(query, 300);
  const url = debouncedQuery ? `/api/search?q=${debouncedQuery}` : null;
  const { data, loading, error } = useFetch(url ?? '');
  return { results: data ?? [], loading, error };
}

function SearchBox() {
  const [query, setQuery] = useState('');
  const { results, loading } = useProductSearch(query);
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      {loading ? 'Searching…' : <ResultsList items={results} />}
    </>
  );
}

Remember

Key Takeaways

  • Custom hooks are a naming convention + composition — React tracks state by CALL ORDER, so hooks must never be conditional.
  • Return the smallest, most stable API (tuple or well-named object) — hide internal hook composition from callers.
  • Prefer several small, focused hooks composed together over one large configurable "mega-hook".
  • A cancelled/stale-response guard (like the `cancelled` flag above) is essential in any data-fetching hook to avoid race conditions.
  • A custom hook can call other custom hooks freely — composition is exactly how the Rules of Hooks are meant to be used.

Do It

Practice

  1. 1Build useLocalStorage(key, initialValue) returning a useState-like tuple that persists to localStorage automatically.
  2. 2Compose useDebouncedValue + useFetch into your own useProductSearch and swap the debounce delay to feel the UX difference.
  3. 3Deliberately violate the Rules of Hooks (call a hook inside an if-block) and read the resulting React warning/error to understand what breaks.