jsguides

Building Custom Hooks in React: Patterns and Gotchas

Introduction

When you find yourself copying and pasting the same useState and useEffect logic between components, that’s a signal to extract it into a custom hook. Building custom hooks lets you package stateful logic into a reusable function that any component can call. This tutorial walks through how to design, write, and test them.

Custom hooks are the primary mechanism for sharing stateful logic in modern React. Unlike older patterns like higher-order components and render props, hooks let you compose behavior without adding wrapper elements to your component tree. A well-designed custom hook reads like a regular function that calls built-in hooks, returning values and callbacks that the component can use directly. Once you understand the pattern, you will reach for it whenever you notice the same hook calls repeating across files.

What is a custom hook?

A custom hook is any JavaScript function whose name starts with use that calls at least one React hook. That’s the whole definition. React has no special API for detecting them — the use prefix is a convention that linters and developers use to identify hooks.

The key insight is that hooks give you a way to attach behavior to React’s rendering lifecycle without using class components, render props, or higher-order components. When you extract logic into a hook, you get the same isolation benefits as a component: each call to a hook gets its own state, but without the rendering overhead.

function useWarmth() {
  const [temperature, setTemperature] = useState(20);
  return { temperature, heat: () => setTemperature(t => t + 1) };
}

State isolation is the key property that makes hooks composable. When two components call useWarmth(), each gets its own temperature and setTemperature. React tracks state by the order of hook calls within a component, not by any global key or name. This is why you can reuse the same hook in many components without any coordination between them.

// Each component that calls useWarmth has its own temperature
function Kitchen() {
  const { temperature, heat } = useWarmth();
  return <button onClick={heat}>Kitchen: {temperature}°C</button>;
}

function Bedroom() {
  const { temperature, heat } = useWarmth();
  return <button onClick={heat}>Bedroom: {temperature}°C</button>;
}

Clicking the kitchen button heats only the kitchen. Clicking the bedroom button heats only the bedroom. The state is isolated per hook call, not shared globally.

The rules of hooks

Custom hooks must follow the same two rules as built-in hooks.

Only call hooks at the top level. Don’t call them inside loops, conditions, or nested functions. React relies on the call order of hooks being identical across renders. If you call a hook conditionally (for example, inside an if block that sometimes runs), then on a render where that condition is false, React’s internal list of state slots gets out of sync with the actual hooks being called.

// ❌ Wrong — hook inside a conditional
function Component({ showCounter }) {
  if (showCounter) {
    const [count, setCount] = useState(0); // breaks hook ordering
  }
  // ...
}

The problem is that when showCounter is false, useState never runs on that render. React’s internal hook index then points to the wrong state slot for every hook that follows. The fix is to call hooks unconditionally at the top and handle the conditional rendering afterward.

// ✅ Correct — always at top level
function Component({ showCounter }) {
  const [count, setCount] = useState(0);
  if (!showCounter) return null;
  // ...
}

Only call hooks from React functions. A hook must be called from a function component or another custom hook. Calling useState from a plain utility function, an event handler, or a class component throws an error.

The ESLint plugin eslint-plugin-react-hooks enforces both rules automatically. Install it and treat warnings as errors.

Building a useToggle Hook

Let’s start with something simple. A useToggle hook manages a boolean value that flips between true and false.

import { useState, useCallback } from 'react';

function useToggle(initial = false) {
  const [value, setValue] = useState(initial);

  const toggle = useCallback(() => {
    setValue(v => !v);
  }, []);

  return [value, toggle];
}

The functional update form setValue(v => !v) always flips from the current value rather than relying on a captured closure value. This avoids a class of stale state bugs. The hook returns a two-element array so destructuring reads naturally, with the toggle action ready for use in an onClick handler.

function App() {
  const [isOn, toggle] = useToggle(false);

  return (
    <div>
      <p>{isOn ? 'ON' : 'OFF'}</p>
      <button onClick={toggle}>Flip</button>
    </div>
  );
}

Return an array because there are exactly two values and consumers typically name them individually.

Syncing State with localStorage

A common need is persisting state across page reloads so that user preferences survive a refresh. useLocalStorage wraps the localStorage API with the same interface as useState, so swapping it in is straightforward.

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // Storage might be unavailable (private browsing, full disk)
    }
  }, [key, value]);

  return [value, setValue];
}

The useState initializer reads from localStorage only on the first render. This lazy initialization pattern avoids hitting storage on every render, which matters if you are reading large values or have performance-sensitive rendering paths. The component below uses the hook to let users pick and persist a theme setting.

function Settings() {
  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>
  );
}

Data Fetching with useFetch

Fetching data from an API involves managing three states — loading, error, and data. A well-designed useFetch hook encapsulates all three so that every component that needs remote data does not have to reimplement the same boilerplate. The hook below tracks each state independently and handles cleanup.

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    let cancelled = false;

    async function load() {
      if (url === null) {
        setLoading(false);
        return;
      }

      try {
        setLoading(true);
        const res = await fetch(url);
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const json = await res.json();
        if (!cancelled) setData(json);
      } catch (err) {
        if (!cancelled) setError(err);
      } finally {
        if (!cancelled) setLoading(false);
      }
    }

    load();
    return () => { cancelled = true; };
  }, [url]);

  return { data, loading, error };
}

The cancelled flag is critical. It prevents the hook from updating state after the component unmounts or after the url changes before a previous fetch resolves. Without it, a slow response arriving after a component has unmounted produces a React warning about updating state on an unmounted component.

The null guard at the top of load() handles cases where the hook is called with no URL yet: for example, when a search component waits for user input before fetching. Returning early avoids a wasted network request and keeps the loading state consistent.

The three-state pattern (loading, error, data) is worth remembering because it covers every possible outcome of an async operation. Skipping any one of the states leaves the UI with an ambiguous condition to display, which leads to flickering, missing error messages, or stale data on screen.

Use it like this:

function UserProfile({ userId }) {
  const { data: user, loading, error } = useFetch(`/api/users/${userId}`);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
  return <h1>{user.name}</h1>;
}

Debouncing Values

Search inputs often need to wait for the user to stop typing before sending a request. useDebounce delays a value update until a timer expires, so the network call only fires once the input settles. Pairing debounce with useFetch is a common pattern in search UIs.

import { useState, useEffect } from 'react';

function useDebounce(value, delay = 300) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

Every time value changes, the cleanup function cancels the previous timer and sets a new one. The debounced value only updates once the dust settles, which prevents hammering an API with a request on every keystroke. This example pairs useDebounce with useFetch for a search input that fetches only after the user pauses:

function Search() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  const { data, loading } = useFetch(
    debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
  );

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Search..."
      />
      {loading && <p>Searching...</p>}
      {data && <ul>{data.map(item => <li key={item.id}>{item.name}</li>)}</ul>}
    </div>
  );
}

Keep each hook focused

Custom hooks are easiest to reuse when they have one clear job. A hook that handles persistence should not also own fetching, and a hook that manages a toggle should not silently reach into unrelated state. That separation makes it easier to name the hook well and easier for the caller to understand what it returns. A compact hook is also simpler to test because there are fewer moving parts to set up.

When hooks are composed, think of each one as a small piece of behavior that can be swapped or moved later. If the pieces stay narrow, you can combine them in different components without rewriting the core logic. That is the real strength of the pattern: less repetition and a cleaner way to organize stateful behavior around the needs of the component tree.

A good rule of thumb is that if you cannot describe what a hook does in one sentence, it probably does too much. Split it into two smaller hooks and compose them at the call site. The caller stays in control of how the pieces fit together, and each hook remains testable in isolation.

Composing hooks together

Custom hooks can call other custom hooks. This is where the pattern becomes powerful: you build small, focused hooks and combine them into more complex behavior.

function useUserPreferences(userId) {
  const [prefs, setPrefs] = useState({});
  const { data: user } = useFetch(`/api/users/${userId}`);

  useEffect(() => {
    if (user) {
      setPrefs(user.defaultPrefs || {});
    }
  }, [user]);

  const updatePref = useCallback((key, value) => {
    setPrefs(p => ({ ...p, [key]: value }));
  }, []);

  return { prefs, updatePref };
}

This hook composes useFetch from earlier and adds its own state management on top. Any component that needs user preferences can call useUserPreferences without knowing anything about how the data is fetched or stored.

Testing custom hooks

Custom hooks need testing too. Use renderHook from @testing-library/react, which provides a test harness for running hooks outside of a real component.

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

test('useToggle starts with initial value', () => {
  const { result } = renderHook(() => useToggle(true));
  expect(result.current[0]).toBe(true);
});

test('useToggle flips value on toggle', () => {
  const { result } = renderHook(() => useToggle(false));

  act(() => {
    const toggle = result.current[1];
    toggle();
  });

  expect(result.current[0]).toBe(true);
});

test('useToggle flips back', () => {
  const { result } = renderHook(() => useToggle(false));

  act(() => {
    const toggle = result.current[1];
    toggle();
    toggle();
  });

  expect(result.current[0]).toBe(false);
});

act() is essential: it flushes all pending state updates synchronously before running assertions. Without it, calling the toggle function from result.current and immediately asserting result.current[0] could catch the state mid-update.

For async hooks like useFetch, use waitFor to wait for loading and data states:

test('useFetch loads data', async () => {
  global.fetch = jest.fn(() =>
    Promise.resolve(new Response(JSON.stringify({ id: 1, name: 'Alice' }), {
      status: 200,
      headers: { 'Content-Type': 'application/json' },
    }))
  );

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

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

Reset mocks in beforeEach to avoid state leaking between tests. When testing async hooks, always mock the external dependency (like fetch) rather than the hook itself. That way your test remains realistic and catches real integration issues.

Common Gotchas

Stale closures are the most frequent hook bug. When you read a value from the enclosing scope inside a callback, that value is captured at the time the callback is created. If the value changes later but the callback still references the old copy, you have a stale closure.

// ❌ Stale: count is always 0 inside the interval
useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1); // captures count from the first render
  }, 1000);
  return () => clearInterval(id);
}, []); // empty deps, runs once

The empty dependency array means the effect runs once on mount, so the count captured inside the closure is always the initial value (0). The solution is to use the functional update form, which receives the latest state from React directly rather than reading it from the enclosing scope.

// ✅ Fixed: functional update always gets the current state
setCount(prev => prev + 1);

Forgetting cleanup in useEffect causes memory leaks and lingering timers. If your effect sets up a subscription, event listener, or timer, return a cleanup function that tears it down.

Lazy initialization matters for expensive first renders. If your initial state requires reading from localStorage, parsing a large JSON object, or running a computation, pass a function to useState instead of the value directly. The function runs only once, on the first render.

// ❌ Runs on every render if React ever re-evaluates (React.memo, etc.)
const [value] = useState(JSON.parse(localStorage.getItem('key')));

// ✅ Runs once, on the first render only
const [value] = useState(() => JSON.parse(localStorage.getItem('key')));

Next steps

Once you have a handle on custom hooks, the natural next step is to learn how React components render and communicate. The React Components and JSX tutorial covers the rendering side of the equation. For the JavaScript fundamentals that make hooks work under the hood, see JavaScript Closures to understand scope capture and JavaScript Promises for the async patterns used by useFetch.

See Also