Overview

Custom hooks let you extract component logic into reusable functions. They are the primary way to share stateful behavior in React without higher-order components or render props. This tutorial covers the rules, several real-world hooks, and testing.

Rules of Hooks

  1. Only call hooks at the top level of a component or another hook. Never inside loops, conditions, or nested functions.
  2. Only call hooks from React function components or custom hooks.
  3. A custom hook's name must start with use. This lets the linter enforce the first two rules.

Your First Custom Hook

import { useState, useEffect } from 'react';

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

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

  return [value, setValue];
}

export default useLocalStorage;

Usage in a component:

function Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Current theme: {theme}
    </button>
  );
}

Example: useFetch

import { useState, useEffect } from 'react';

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    async function load() {
      setLoading(true);
      setError(null);

      try {
        const response = await fetch(url, { signal: controller.signal });
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        setData(await response.json());
      } catch (err) {
        if (err.name !== 'AbortError') setError(err);
      } finally {
        setLoading(false);
      }
    }

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

  return { data, loading, error };
}

The AbortController cancels in-flight requests when the URL changes or the component unmounts, preventing state updates on unmounted components.

Example: useDebounce

import { useState, useEffect } from 'react';

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

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

  return debounced;
}
function Search() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 400);
  const { data } = useFetch(`/api/search?q=${debouncedQuery}`);

  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <Results items={data} />
    </>
  );
}

Example: useMediaQuery

import { useState, useEffect } from 'react';

function useMediaQuery(query) {
  const [matches, setMatches] = useState(() =>
    typeof window !== 'undefined' ? window.matchMedia(query).matches : false
  );

  useEffect(() => {
    const media = window.matchMedia(query);
    const listener = (event) => setMatches(event.matches);

    media.addEventListener('change', listener);
    setMatches(media.matches);

    return () => media.removeEventListener('change', listener);
  }, [query]);

  return matches;
}
function Layout() {
  const isDesktop = useMediaQuery('(min-width: 1024px)');
  return isDesktop ? <DesktopNav /> : <MobileNav />;
}

Example: useInterval

import { useEffect, useRef } from 'react';

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

  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

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

The ref pattern keeps the interval stable while always invoking the latest callback. Passing null as the delay pauses the interval.

Composing Hooks

Custom hooks can call other custom hooks. This makes them composable building blocks.

function useUserProfile(userId) {
  const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
  const isDesktop = useMediaQuery('(min-width: 1024px)');

  return { user, loading, error, isDesktop };
}

When to Extract a Custom Hook

SignalAction
Same useState + useEffect pattern in two or more componentsExtract a hook
Component exceeds ~150 linesSplit logic into hooks
Complex effect with cleanupIsolate in a named hook
Logic needs unit tests without rendering a full componentExtract and test with renderHook

Testing Custom Hooks

import { renderHook, act, waitFor } from '@testing-library/react';
import useLocalStorage from './useLocalStorage';

test('persists value to localStorage', () => {
  const { result } = renderHook(() => useLocalStorage('key', 'initial'));

  expect(result.current[0]).toBe('initial');

  act(() => result.current[1]('updated'));

  expect(result.current[0]).toBe('updated');
  expect(window.localStorage.getItem('key')).toBe('"updated"');
});

test('useFetch returns data', async () => {
  global.fetch = jest.fn(() =>
    Promise.resolve({ ok: true, json: () => Promise.resolve({ id: 1 }) })
  );

  const { result } = renderHook(() => useFetch('/api/user'));

  await waitFor(() => expect(result.current.loading).toBe(false));
  expect(result.current.data).toEqual({ id: 1 });
});

Naming and API Conventions

  • Return an array when the order is obvious and stable, like [value, setValue].
  • Return an object when there are three or more values, so callers use named properties.
  • Accept an options object for anything beyond one or two parameters.
  • Document cleanup behavior: what happens on unmount, and whether the hook aborts or cancels work.
  • Avoid returning a new object or function on every render without memoization if consumers put it in a dependency array.

Common Pitfalls

PitfallProblemFix
Conditional hook callsHook order changes between rendersAlways call hooks unconditionally at the top
Missing cleanupMemory leaks, state updates on unmounted componentsReturn a cleanup function from useEffect
Stale closuresHook reads outdated stateAdd to dependency array or use a ref
Returning a new function each renderConsumer effects re-runWrap in useCallback
Sharing state between hook instancesUnexpected couplingEach hook call has its own state by design