Skip to the chapter
←ReactGGroundwork⚛React
React/Intermediate/I4
15 / 57 written chapters
/

The cover
  1. B1Setup & JSX
  2. B2Thinking in React
  3. B3Components
  4. B4Props
  5. B5useState
  6. B6Events & conditionals
  7. B7Lists & keys
  8. B8Forms
  9. B9useEffect
  10. B10Lifting state & styling
  11. B11Guided project
  12. ★Cheat page
  1. I1useRef
  2. I2Context
  3. I3useReducer
  4. I4Custom hooks
  5. I5Effects in depth
  6. I6Routing
  7. I7State architecture
  8. I8Memoisation
  9. I9Boundaries & portals
  10. I10Data fetching
  11. I11Forms at scale
  12. I12State libraries
  13. I13Auth
  14. I14TanStack Query
  15. I15Testing
  16. I16TypeScript
  17. I17Animation
  18. I18E2E testing
  19. I19i18n
  20. I20File uploads
  21. I21Real-time UI
  22. I22shadcn/ui
  23. ★Cheat page
  1. A1Fiber
  2. A2Concurrent
  3. A3Suspense
  4. A4Server Components
  5. A5Server Actions
  6. A6use()
  7. A7Rendering strategies
  8. A8Effect timing
  9. A9External stores
  10. A10Refs in depth
  11. A11Component patterns
  12. A12Performance
  13. A13Accessibility
  14. A14Design systems
  15. A15Advanced TypeScript
  16. A16Architecture at scale
  17. A17Custom renderers
  18. A18Migrating & upgrading
  19. A19Production debugging
  20. A20Review & judgement
  21. A21State machines
  22. A22Feature flags
  23. A23Interview bank
  24. ★Cheat page

⚛I4 · Part 2 · Intermediate

Custom hooks

Sharing stateful logic between components — not sharing the state itself.

3 min read8 sections6 exercises
15 of 57

In this chapter

  • A custom hook is just a function
  • What to return
  • Three that earn their keep
  • Extracting one from a component
  • Composing hooks

and 3 more

about 3 min
08On this page
  1. A custom hook is just a function
  2. What to return
  3. Three that earn their keep
  4. Extracting one from a component
  5. Composing hooks
  6. useDebugValue
  7. Mistakes worth avoiding
  8. The dependency question

A custom hook is just a function

There is no API for this. A custom hook is a function whose name starts with use and which calls other hooks. That naming is not decoration: it is how the linter knows to apply the rules of hooks to it.

function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((v) => !v), []);
  return [on, toggle];
}

const [isOpen, toggleOpen] = useToggle();
Each call gets its own state

Two components calling useToggle() do not share a value. A custom hook shares logic, never state — the hooks inside run against whichever component is calling. If you want shared state, that is context or a store.

What to return

return [value, setValue];              // tuple — when the caller renames both
return { data, error, isLoading };     // object — when there are several, or optional

Tuples for two values that callers will rename, the way useState does. Objects once there are three or more, so the caller destructures only what it needs and adding a field later is not a breaking change.

Three that earn their keep

Reading and writing localStorage

function useLocalStorage(key, initial) {
  const [value, setValue] = useState(() => {
    try {
      const raw = localStorage.getItem(key);
      return raw ? JSON.parse(raw) : initial;
    } catch {
      return initial;                    // private mode, quota, bad JSON
    }
  });

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

  return [value, setValue];
}

Both accesses are wrapped. localStorage throws in private browsing on some browsers, when the quota is full, and when a page has storage blocked — an unguarded read takes the whole component down.

A media query

function useMediaQuery(query) {
  const [matches, setMatches] = useState(() => window.matchMedia(query).matches);

  useEffect(() => {
    const mql = window.matchMedia(query);
    const onChange = (e) => setMatches(e.matches);
    setMatches(mql.matches);                  // in case it changed before subscribing
    mql.addEventListener("change", onChange);
    return () => mql.removeEventListener("change", onChange);
  }, [query]);

  return matches;
}

In a server-rendered app the lazy initialiser would crash — there is no window. That case is what useSyncExternalStore exists for; it takes a separate server snapshot.

Debouncing a value

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

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

  return debounced;
}

const query = useDebounced(input);   // fetch on this, render on input

Extracting one from a component

The signal is not length. It is a group of hooks that always move together and that another component would want whole.

// before: this block appears in three components
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => { ... }, [userId]);

// after
const { user, loading, error } = useUser(userId);

The test for a good extraction: the component that uses it reads better, and the hook makes sense with a name that does not mention the component it came from. useDashboardTopSection is a function you moved; useUser is an abstraction.

Composing hooks

function useSearch(endpoint) {
  const [input, setInput] = useState("");
  const query = useDebounced(input, 300);        // hooks calling hooks
  const { data, loading } = useFetch(endpoint + "?q=" + query);
  return { input, setInput, results: data ?? [], loading };
}

This is where custom hooks pay off. Each piece is testable and replaceable on its own, and the composition reads like a description of the feature.

useDebugValue

function useOnlineStatus() {
  const isOnline = useSyncExternalStore(subscribe, getSnapshot);
  useDebugValue(isOnline ? "Online" : "Offline");   // label in DevTools
  return isOnline;
}

Purely a development affordance: React DevTools shows OnlineStatus: "Online" next to the hook instead of a bare true. It is stripped from production builds.

Worth it for a hook used across a codebase or shipped in a library, where somebody debugging will meet it without having read it. Not worth it for a hook used in one component. If formatting the value is expensive, pass a function as the second argument — it only runs when DevTools is open.

Mistakes worth avoiding

MistakeWhy it hurts
Calling a hook conditionally inside your hookSame rule, same corruption — call order must be stable
A hook that takes ten options and does five jobsNobody can use half of it. Split it.
Returning a new object without memoisingCallers putting it in a dependency array get an effect on every render
Naming it without useThe lint rules stop applying, silently
Wrapping a single useStateuseName() that returns useState("") adds a file and no meaning

The dependency question

function useFetch(url) {
  const [data, setData] = useState(null);
  useEffect(() => {
    let cancelled = false;
    fetch(url).then((r) => r.json()).then((d) => { if (!cancelled) setData(d); });
    return () => { cancelled = true; };
  }, [url]);                            // a string — safe to depend on
  return data;
}

Take primitives as arguments where you can. A hook whose parameter is an options object re-runs whenever the caller renders, because the caller almost certainly wrote the object inline — and now the fix lives in every call site instead of in the hook.

Interview answer, one sentence

"A custom hook is a function starting with use that calls other hooks, and it shares logic rather than state — each caller gets its own. Extract one when a group of hooks always changes together and another component would want the whole group."

06Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 10 testsThe read behind useLocalStorageStateintermediate
  • 10 testsThe logic behind usePaginationintermediate
  • 8 testsuseDebounceintermediate
  • 8 testsuseToggleintermediate
  • 8 testsuseOnClickOutsideintermediate
  • 9 testsusePersistentStateintermediate
All 66 for this level →
I4

Finished reading?

0 of 57 chapters read in this series

→Next stop

  1. I3Previous · I3useReducer
  2. I4you are here
  3. Next · I5Effects in depthStale closures, the dependency array as a contract, and the effects that should not exist.5 min read

01On this page

8 sections · 0%

  1. 01A custom hook is just a function
  2. 02What to return
  3. 03Three that earn their keep
  4. 04Extracting one from a component
  5. 05Composing hooks
  6. 06useDebugValue
  7. 07Mistakes worth avoiding
  8. 08The dependency question

[] chapters · t top