Kodokon kodokon.com

Custom hooks: extracting and reusing logic

Extract stateful logic into reusable, composable, testable hooks.

8 min · 3 questions

Open this lesson in Kodokon

A custom hook is a function whose name starts with use and that calls other hooks. It extracts stateful logic, not shared state: every component that calls it gets its own useState and useEffect, fully isolated. It is React's most direct tool for factoring out logic - where HOCs and render props added layers to the tree, a hook slips into the body of the component, composes easily, and is easy to test.

JAVASCRIPT
import { useEffect, useState } from "react";

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

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

  return debounced;
}
The cleanup cancels the previous timer on every change.

The use prefix is not decorative: it is what lets the react-hooks ESLint plugin check the rules of hooks inside your function - unconditional calls, at the top level. Also take care with the shape of the returned API: a [value, setValue] tuple when the usage mirrors useState, an object with named keys as soon as there are more than two things to expose.

JAVASCRIPT
function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = localStorage.getItem(key);
    return stored !== null
      ? JSON.parse(stored)
      : initialValue;
  });

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

  return [value, setValue];
}
Lazy initialization: localStorage is only read on mount.

Hooks compose: a hook can call other hooks, including your own. That is how readable business abstractions emerge, built on generic building blocks. Note the pairing of AbortController with the cleanup function: without it, a request fired for an older input can respond after the most recent one and overwrite its result - the classic race condition of search fields.

JAVASCRIPT
function useUserSearch(query) {
  const debounced = useDebouncedValue(query, 400);
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (debounced === "") {
      setResults([]);
      return;
    }
    const controller = new AbortController();
    fetch("/api/search?q=" + debounced, {
      signal: controller.signal,
    })
      .then((res) => res.json())
      .then(setResults)
      .catch(() => {});
    return () => controller.abort();
  }, [debounced]);
  return results;
}
The business hook builds on the generic hook.

Knowledge check

Make sure you remember the key points of this lesson.

  1. What is the use prefix of a custom hook actually for?
    • It is a purely cosmetic convention inherited from classes
    • It lets the linter identify the function as a hook and check the rules of hooks inside it
    • React refuses to run a stateful function without this prefix
    • It triggers the automatic import from react
  2. Two components call useLocalStorage with the same key. Do they share their state?
    • Yes: the hook automatically synchronizes all of its callers
    • No, and the data is persisted under two different storage keys
    • No: each call owns its own React state; only the data in localStorage is shared
  3. In useDebouncedValue, what is the role of the cleanup function returned by useEffect?
    • Cancel the pending timer so that only the last typed value is published after the delay
    • Reset the debounced value every time the component unmounts
    • Block the component's re-renders for the entire duration of the delay