Custom Hooks in React

A custom hook is a function whose name starts with use and that can call other hooks inside it. Custom hooks let you extract stateful logic from components and share it between them without changing your component tree.

Why custom hooks exist

Suppose two components both need to fetch a URL and track whether the request is loading or has errored. Without custom hooks, your options are:

  • Copy the useState and useEffect setup into both components (duplication).
  • Create a wrapper component that fetches data and passes it down via props (awkward structure).

Custom hooks give you a third option: extract the logic into a function, call it from both components, and keep your component code focused on rendering.

The naming rule

The use prefix is required, not just a style choice. React uses it to identify hooks and enforce the rules of hooks on them. If you write a function called fetchData that calls useEffect internally, React will not apply hook rules to it, which can cause subtle bugs. Name it useFetchData instead.

A first custom hook: useWindowWidth

Here is a simple hook that tracks the browser window width:

import { useState, useEffect } from 'react';

function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return width;
}

export default useWindowWidth;

Now any component can use it with one line:

function Navbar() {
  const width = useWindowWidth();
  return <nav>{width < 768 ? <MobileMenu /> : <DesktopMenu />}</nav>;
}

function Chart() {
  const width = useWindowWidth();
  return <svg width={width * 0.8}>{/* chart content */}</svg>;
}

Navbar and Chart know nothing about the event listener setup. Each gets its own independent state; they do not share a single width value.

useFetch

A practical hook for data fetching. It accepts a URL and returns { data, isLoading, error }:

import { useState, useEffect } from 'react';

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

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

    async function fetchData() {
      setIsLoading(true);
      setError(null);

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

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

  return { data, isLoading, error };
}

export default useFetch;

Use it in any component that needs data from a URL:

function PostList() {
  const { data: posts, isLoading, error } = useFetch('https://jsonplaceholder.typicode.com/posts');

  if (isLoading) return <p>Loading posts...</p>;
  if (error) return <p>Error: {error}</p>;

  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

function UserProfile({ userId }) {
  const { data: user, isLoading, error } = useFetch(`https://jsonplaceholder.typicode.com/users/${userId}`);

  if (isLoading) return <p>Loading profile...</p>;
  if (error) return <p>Error: {error}</p>;

  return <h2>{user?.name}</h2>;
}

Both components get the full fetch pattern with no duplication. If you later add caching or retry logic to useFetch, every component using it gets the improvement automatically.

useLocalStorage

A hook that works like useState but persists the value to localStorage. It takes a storage key and an initial value:

import { useState } from 'react';

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

  const setValue = (value) => {
    const valueToStore = value instanceof Function ? value(storedValue) : value;
    setStoredValue(valueToStore);
    localStorage.setItem(key, JSON.stringify(valueToStore));
  };

  return [storedValue, setValue];
}

export default useLocalStorage;

Use it like useState. The value survives page refreshes:

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

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

The rules still apply

Custom hooks must follow the same rules as built-in hooks:

  • Only call hooks at the top level of your custom hook. Do not call them inside if blocks, loops, or nested functions within the hook.
  • Only call hooks from React functions (components or other custom hooks). Do not call them from plain JavaScript functions.

These rules apply to the hooks your custom hook calls internally. React traces the full call chain.

When to extract a custom hook

Good signals:

  • You find yourself copying the same useState and useEffect pattern into multiple components.
  • A component has a large block of hook calls that are not directly about what the component renders.
  • The logic has its own clearly defined inputs and outputs.

Bad signals:

  • The logic is only used in one place and is unlikely to be reused.
  • The logic is short and simple enough that extracting it adds more indirection than clarity.

Do not extract a custom hook just because you can. Extract it when the abstraction is genuinely useful.