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
| Rule | Why | Breaks like this |
|---|---|---|
| Call hooks only at the top level of a component or custom hook | The order of calls must be identical on every render | if (user) { const [x, setX] = useState(0); } |
| Call hooks only from components or other hooks | React must know which component owns the state | Calling useState inside a plain function or inside a click handler |
| Name custom hooks starting with use | Lets the linter check these rules for you | A 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.
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.
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.
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.
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.
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.
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.
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
- 1Spot repeated or tangled logic: the same
useStateplususeEffectpattern in two components. - 2Create a file such as
src/hooks/useThing.jsand move the state and effect into a function nameduseThing. - 3Turn the changing parts (a URL, a key, a delay) into parameters.
- 4Return what the component needs: an object for several values, an array for a
useState-like pair. - 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
- Is the same stateful logic used in two or more places, or does one component mix unrelated concerns that would read better apart?
- Does the name say what it does, starting with
use(useFetch,useOnlineStatus)? - Does it return only what callers need (an object for many values, an array for a
useState-like pair)? - Does it clean up everything it starts (timers, listeners, requests)?
- 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.
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
- A custom hook is a function starting with
usethat calls other hooks; it shares logic, not state. - Call hooks at the top level, never inside conditions, loops or nested functions, and only from components or hooks.
useFetch,useLocalStorageanduseDebounceare small, reusable patterns worth knowing by heart.- Pass a function to
useStatefor expensive initial values such as readinglocalStorage. - 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.