Web Development with React
Hands-onIntermediate 30 min· React core · 11 of 21

Custom hooks

Learn the rules of hooks and how to pull repeated stateful logic into your own functions. You will write useFetch, useLocalStorage and useDebounce hooks and use them to build a Brew & Bean cart that survives a page refresh.

In this tutorial you will extract repeated logic from components into custom hooks. You will write three small ones: useFetch (load data), useLocalStorage (state that survives a refresh) and useDebounce (wait until the user stops typing). Then you will use them in a Brew & Bean menu with a cart.

Prerequisites: a Vite + React project, useState, and useEffect with cleanup (see *useEffect and fetching data*). The file public/menu.json from that lesson is used again here.

What is a custom hook?

A custom hook is an ordinary JavaScript function whose name starts with use and that calls other hooks inside it. That is all. It lets you share logic between components. It does not share state: every component that calls the hook gets its own separate copy of the state inside it.

If Menu and OrderHistory both fetch data with the same 20 lines of useState and useEffect, you have a candidate for a hook. Like a recipe card in the kitchen: each cook (component) follows the same recipe but uses their own ingredients.

The rules of hooks

React tracks hooks by the order they are called in on every render, so the order must never change. Two rules follow from this.

Rules of hooks

RuleWhyBreaks like this
Call hooks only at the top level of a component or custom hookThe order of calls must be identical on every renderif (user) { const [x, setX] = useState(0); }
Call hooks only from components or other hooksReact must know which component owns the stateCalling useState inside a plain function or inside a click handler
Name custom hooks starting with useLets the linter check these rules for youA function called getData that uses useState

Let the linter help

The Vite React template includes the eslint-plugin-react-hooks plugin. If your editor underlines a hook call, read the message: it is almost always about a condition, a loop, or a function name that does not start with use.

Hook 1: useFetch

Here is the fetch code from the previous lesson, moved into a function. It takes a URL and returns an object with the three things a component needs: data, loading and error. The cleanup aborts the old request when the URL changes.

src/hooks/useFetch.js
import { useEffect, useState } from 'react';

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

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    setError(null);

    async function load() {
      try {
        const response = await fetch(url, { signal: controller.signal });
        if (!response.ok) {
          throw new Error('Request failed with status ' + response.status);
        }
        setData(await response.json());
        setLoading(false);
      } catch (err) {
        if (err.name === 'AbortError') return;
        setError(err.message);
        setLoading(false);
      }
    }

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

  return { data, loading, error };
}

Every value the effect uses (url) is in the dependency array. setData and friends are stable and need not be listed.

src/Menu.jsx
import { useFetch } from './hooks/useFetch';

function Menu() {
  const { data: menu, loading, error } = useFetch('/menu.json');

  if (loading) return <p>Loading menu...</p>;
  if (error) return <p role="alert">{error}</p>;

  return (
    <ul>
      {menu.map((item) => (
        <li key={item.id}>
          {item.name} - ₹{item.price}
        </li>
      ))}
    </ul>
  );
}

export default Menu;

The component shrank from 40 lines to 15 and reads like a description of what the screen shows. data: menu renames the field while destructuring.

Hook 2: useLocalStorage

localStorage is a small key-value store in the browser that survives page refreshes. It only stores strings, so we convert with JSON.stringify and JSON.parse. Our hook looks like useState, but also saves every change.

We pass a function to useState as the initial value. React calls it only on the first render, so we read from storage once, not on every render. We wrap reads in try/catch because storage can be blocked (private mode) or contain broken JSON.

src/hooks/useLocalStorage.js
import { useEffect, useState } from 'react';

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

  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // storage full or blocked: keep working without saving
    }
  }, [key, value]);

  return [value, setValue];
}

It returns an array [value, setValue], the same shape as useState, so it can replace useState with one word changed.

Now a cart. The cart is an array of { item_id, qty }, the same shape the order API expects. Because we use useLocalStorage, refreshing the page does not empty the cart.

src/Cart.jsx
import { useLocalStorage } from './hooks/useLocalStorage';
import { useFetch } from './hooks/useFetch';

function Cart() {
  const { data: menu, loading, error } = useFetch('/menu.json');
  const [cart, setCart] = useLocalStorage('bb-cart', []);

  function addItem(itemId) {
    setCart((current) => {
      const existing = current.find((line) => line.item_id === itemId);
      if (existing) {
        return current.map((line) =>
          line.item_id === itemId ? { ...line, qty: line.qty + 1 } : line
        );
      }
      return [...current, { item_id: itemId, qty: 1 }];
    });
  }

  if (loading) return <p>Loading...</p>;
  if (error) return <p role="alert">{error}</p>;

  const total = cart.reduce((sum, line) => {
    const item = menu.find((m) => m.id === line.item_id);
    return sum + (item ? item.price * line.qty : 0);
  }, 0);

  return (
    <div>
      <h2>Menu</h2>
      <ul>
        {menu.map((item) => (
          <li key={item.id}>
            {item.name} - ₹{item.price}{' '}
            <button onClick={() => addItem(item.id)}>Add</button>
          </li>
        ))}
      </ul>

      <h2>Your cart</h2>
      {cart.length === 0 ? (
        <p>Your cart is empty.</p>
      ) : (
        <ul>
          {cart.map((line) => (
            <li key={line.item_id}>
              {line.item_id} x {line.qty}
            </li>
          ))}
        </ul>
      )}
      <p>Preview total: ₹{total}</p>
      <button onClick={() => setCart([])}>Clear cart</button>
    </div>
  );
}

export default Cart;

Hooks (useFetch, useLocalStorage) are called at the top, before the early returns. The total is only a preview; the server calculates the real total.

What you should see
Click Add on Espresso twice and Masala Chai once: the cart shows espresso x 2 and masala-chai x 1, total ₹330. Refresh the page: the cart is still there. In DevTools, Application tab, Local Storage, you can see the key `bb-cart`.

Never keep secrets in localStorage

Any script running on your page can read localStorage. It is fine for a cart or a theme choice, not for passwords or access tokens you cannot afford to leak.

Hook 3: useDebounce

Debouncing means waiting until the user pauses before acting. If a search box called the server on every key press, typing 'cappuccino' would send ten requests. useDebounce takes a value and returns a copy of it that only updates after the value has stopped changing for some milliseconds. It uses an effect with cleanup: each change cancels the previous timer.

src/hooks/useDebounce.js
import { useEffect, useState } from 'react';

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

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

  return debounced;
}

Each keystroke changes value, cleanup clears the old timer, and a new timer starts. Only after 400 ms of silence does debounced change.

src/SearchMenu.jsx
import { useState } from 'react';
import { useDebounce } from './hooks/useDebounce';
import { useFetch } from './hooks/useFetch';

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

  // With a real API: '/menu?search=' + encodeURIComponent(debouncedQuery)
  const { data: menu, loading } = useFetch('/menu.json');

  const q = debouncedQuery.trim().toLowerCase();
  const results = (menu || []).filter((item) => item.name.toLowerCase().includes(q));

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search the menu"
        aria-label="Search the menu"
      />
      {loading && <p>Loading...</p>}
      <ul>
        {results.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

export default SearchMenu;

The text box updates instantly (query) while the filter, or the API request in a real app, follows the debounced value.

How to extract a hook

  1. 1Spot repeated or tangled logic: the same useState plus useEffect pattern in two components.
  2. 2Create a file such as src/hooks/useThing.js and move the state and effect into a function named useThing.
  3. 3Turn the changing parts (a URL, a key, a delay) into parameters.
  4. 4Return what the component needs: an object for several values, an array for a useState-like pair.
  5. 5Replace the old code in each component with one call to the hook, and check the screen behaves the same.

Designing good hooks

Before you extract a hook

  1. Is the same stateful logic used in two or more places, or does one component mix unrelated concerns that would read better apart?
  2. Does the name say what it does, starting with use (useFetch, useOnlineStatus)?
  3. Does it return only what callers need (an object for many values, an array for a useState-like pair)?
  4. Does it clean up everything it starts (timers, listeners, requests)?
  5. Are all values used inside its effects in the dependency arrays?

Do not extract a hook just to hide code. A hook with no state or effect inside is just a function; name it normally. And remember that two components using the same hook do not share data. To share state between components, lift it up or use Context (see *Managing app state: Context, Zustand and server state*).

How this comes up in interviews: "What are the rules of hooks and why do they exist?" (call order is how React matches state to hooks) and "Write a useDebounce or useLocalStorage hook." Both hooks above are standard answers.

Prompt · Ask AI to turn repeated code into a hook
I am learning React. These two components repeat similar logic. Suggest a custom hook that removes the duplication. Show the hook, show both components rewritten to use it, and explain what the hook returns and why. Keep it under 40 lines and follow the rules of hooks.

---
Component A:
[paste]

Component B:
[paste]
---

What to expect: Check that the hook name starts with use, hooks are called unconditionally at the top level, and effects clean up after themselves.

Try it: Write your own hooks

1. Write useOnlineStatus() that returns true or false using navigator.onLine and the window events online and offline. Show a banner 'You are offline' in your app. Clean up the listeners. 2. Write useToggle(initial = false) that returns [value, toggle]. 3. Add + and - buttons to each cart line (remove the line when qty reaches 0). 4. Replace the fetch in Cart.jsx with useFetch for an order status URL and poll it every 10 seconds by adding an optional interval argument.

Key takeaways

  1. A custom hook is a function starting with use that calls other hooks; it shares logic, not state.
  2. Call hooks at the top level, never inside conditions, loops or nested functions, and only from components or hooks.
  3. useFetch, useLocalStorage and useDebounce are small, reusable patterns worth knowing by heart.
  4. Pass a function to useState for expensive initial values such as reading localStorage.
  5. Always clean up timers, listeners and requests started inside hooks.

Check yourself

Finishing the quiz marks the lesson as done.

1. Two components both call useFetch('/menu.json'). What is true?

2. Why is if (open) { const [x, setX] = useState(0); } not allowed?

3. What does the function passed to useState(() => readFromStorage()) do?

4. What problem does useDebounce solve in a search box?

5. Which is NOT a good reason to store data in localStorage?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free