Skip to content
elephantoo

Custom hooks

Lesson 15 of 30 15 min read

Extract reusable stateful logic into your own use… functions: useLocalStorage, useFetch, useDebouncedValue and more.


As you build components, you'll notice the same hook logic appearing again and again: subscribing to the window size, syncing state with localStorage, fetching data, debouncing input. Custom hooks let you extract that logic into a function with a good name and reuse it anywhere. They're one of React's most powerful ideas — and they're just functions.

Your first custom hook#

Here's an online-status effect from an earlier lesson:

JSX
function StatusBar() {
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    const on = () => setIsOnline(true);
    const off = () => setIsOnline(false);
    window.addEventListener("online", on);
    window.addEventListener("offline", off);
    return () => {
      window.removeEventListener("online", on);
      window.removeEventListener("offline", off);
    };
  }, []);
  return <p>{isOnline ? "✅ Online" : "❌ Disconnected"}</p>;
}

Move it into a function whose name starts with use:

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

export function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(() => navigator.onLine);

  useEffect(() => {
    const on = () => setIsOnline(true);
    const off = () => setIsOnline(false);
    window.addEventListener("online", on);
    window.addEventListener("offline", off);
    return () => {
      window.removeEventListener("online", on);
      window.removeEventListener("offline", off);
    };
  }, []);

  return isOnline;
}

function StatusBar() {
  const isOnline = useOnlineStatus();
  return <p>{isOnline ? "✅ Online" : "❌ Disconnected"}</p>;
}

function SaveButton() {
  const isOnline = useOnlineStatus();
  return <button disabled={!isOnline}>{isOnline ? "Save" : "Reconnecting…"}</button>;
}

Components now say what they need (useOnlineStatus()), not how it works.

The rules#

  1. Name it useSomething. The prefix tells React's linter to check the Rules of Hooks inside it, and tells readers it may contain state or effects.
  2. Follow the Rules of Hooks: call hooks only at the top level of the custom hook, never conditionally.
  3. Each call is independent. Custom hooks share logic, not state. Two components calling useOnlineStatus() each get their own state variable (they just happen to hold the same value).
  4. If a function doesn't call any hooks, it doesn't need to be a hook — make it a regular function (formatPrice, not useFormatPrice).

useToggle#

JSX
import { useCallback, useState } from "react";

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

function Accordion({ title, children }) {
  const [open, toggleOpen] = useToggle();
  return (
    <section>
      <button aria-expanded={open} onClick={toggleOpen}>
        {open ? "▾" : "▸"} {title}
      </button>
      {open && <div>{children}</div>}
    </section>
  );
}

Returning an array (like useState) lets callers name the values; returning an object ({ on, toggle }) is clearer when there are many values.

useLocalStorage: state that survives a refresh#

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

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

  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // storage full or unavailable – keep working in memory
    }
  }, [key, value]);

  return [value, setValue];
}

function ThemePicker() {
  const [theme, setTheme] = useLocalStorage("theme", "light");
  return (
    <select value={theme} onChange={(e) => setTheme(e.target.value)}>
      <option value="light">Light</option>
      <option value="dark">Dark</option>
    </select>
  );
}

It has exactly the same API as useState, so swapping one for the other is a one-word change.

useDebouncedValue#

Wait until the user stops typing before searching:

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

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

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id); // a new keystroke cancels the pending update
  }, [value, delay]);

  return debounced;
}

function Search() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebouncedValue(query, 400);
  const { data, loading } = useFetch(
    debouncedQuery ? `https://jsonplaceholder.typicode.com/posts?q=${encodeURIComponent(debouncedQuery)}` : null
  );

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search posts" />
      {loading && debouncedQuery && <p>Searching…</p>}
      <ul>{data?.slice(0, 5).map((p) => <li key={p.id}>{p.title}</li>)}</ul>
    </>
  );
}

Hooks compose: Search combines useState, useDebouncedValue and useFetch (from the Fetching data lesson). The input stays responsive while requests only fire 400 ms after typing stops.

useFetch, improved#

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

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

  useEffect(() => {
    if (!url) {
      setState({ data: null, error: null, loading: false });
      return;
    }
    const controller = new AbortController();
    setState((s) => ({ ...s, loading: true, error: null }));

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((data) => setState({ data, error: null, loading: false }))
      .catch((error) => {
        if (error.name !== "AbortError") setState({ data: null, error, loading: false });
      });

    return () => controller.abort();
  }, [url]);

  return state;
}

Keeping the previous data while loading avoids the list flickering empty on each keystroke.

useMediaQuery#

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

export 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);
    mql.addEventListener("change", onChange);
    return () => mql.removeEventListener("change", onChange);
  }, [query]);

  return matches;
}

function Nav() {
  const isMobile = useMediaQuery("(max-width: 640px)");
  const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
  return isMobile ? <HamburgerMenu animate={!prefersReducedMotion} /> : <FullMenu />;
}

Hooks that accept callbacks#

When a hook takes a callback (an event handler), you usually want the latest version without re-subscribing every render. Store it in a ref:

JSX
import { useEffect, useRef } from "react";

export function useInterval(callback, delay) {
  const savedCallback = useRef(callback);

  useEffect(() => {
    savedCallback.current = callback; // always call the newest callback
  }, [callback]);

  useEffect(() => {
    if (delay === null) return; // pass null to pause
    const id = setInterval(() => savedCallback.current(), delay);
    return () => clearInterval(id);
  }, [delay]);
}

function Countdown({ from = 10 }) {
  const [left, setLeft] = useState(from);
  useInterval(() => setLeft(left - 1), left > 0 ? 1000 : null);
  return <p>{left > 0 ? `${left}…` : "Lift off! 🚀"}</p>;
}

Since React 19.2 there's an official hook for this pattern, useEffectEvent. It wraps a function so that it always sees the latest props and state, but doesn't count as an effect dependency:

JSX
import { useEffect, useEffectEvent } from "react";

export function useInterval(callback, delay) {
  const onTick = useEffectEvent(callback); // always calls the newest callback

  useEffect(() => {
    if (delay === null) return;
    const id = setInterval(() => onTick(), delay);
    return () => clearInterval(id);
  }, [delay]); // onTick is deliberately not a dependency
}

Only call effect events from inside effects. Don't pass them to other components or call them during rendering.

Designing good custom hooks#

  • Name for the purpose, not the mechanism: useOnlineStatus, useCart, useCourseProgress — not useEffectWithListener.
  • Keep inputs and outputs simple: take plain values, return values and functions.
  • Don't wrap effects for the sake of it. A custom hook should make the component simpler to read. Avoid "lifecycle" hooks like useMount(fn) — they hide dependencies.
  • Test them with React Testing Library's renderHook (see Testing).
  • Check for existing ones: libraries like usehooks-ts and @uidotdev/usehooks provide well-tested versions of common hooks.

Common mistakes#

  • Expecting two components using the same hook to share state.
  • Calling a custom hook conditionally or inside a loop.
  • Naming a non-hook helper useSomething (or a hook without use, which disables lint checks).
  • Returning new objects or functions every render when callers use them as effect dependencies — memoise with useMemo/useCallback where it matters.

What's next#

Next: lifting state up and composition patterns — how to structure components so data flows cleanly.

Check your understanding

Quick quiz

0/3 answered
  1. 1.What must a custom hook's name start with?

  2. 2.Two components both call useToggle(). Do they share the same state?

  3. 3.Which is a good reason to write a custom hook?

Finished reading?

Mark this lesson complete to track your progress.