Goal: extract stateful logic that several components share into custom hooks, follow the rules of hooks, and design hooks with small, predictable inputs and outputs.
By now the tool library has logic that keeps turning up: checking a tool’s availability, waiting for the member to stop typing before filtering, remembering the basket. Copying an effect from one component to the next works until the copies drift apart and a bug fixed in one survives in the other. A custom hook lets you write that logic once, give it a name, and use it in any component. It is the main way React code is reused, and most hooks you will meet in libraries are custom hooks built from the ones you already know.
In this lesson
Why extract a hook
In Lesson 9 the tool card loaded its next free date with an effect and an ignore flag. Now the tool details page needs the same date. Without a custom hook, the details page gets a copy of the same state and effect:
function ToolDetails({ tool }) {
const [result, setResult] = useState(null);
useEffect(() => {
let ignore = false;
fetchAvailability(tool.id).then((data) => {
if (!ignore) setResult(data);
});
return () => {
ignore = true;
};
}, [tool.id]);
// ...and the same ten lines again in ToolCard.
}
You can’t move this into an ordinary helper function, because it calls hooks, and hooks need to run as part of a component’s render. A custom hook is the answer: a JavaScript function whose name starts with use and which calls other hooks. You move the state and effect into it, return what the component needs, and call it from both components. Each component then reads like a description of what it shows, with the “how” tucked away behind a good name: const { loading, nextFreeDate } = useAvailability(tool.id);.
There is nothing special about a custom hook as far as React is concerned. React doesn’t register it or know it exists. When your component calls useAvailability, the useState and useEffect inside it run exactly as if you had written them in the component.
A hook shares logic, not state
This is the point that trips people up most. Every call to a custom hook gets its own, independent state. Two components using the same hook are like two components that each contain the same useState: same code, separate values. Here is a tiny useToggle hook used by two tool cards. Press the first card’s button:
import { useState } from 'react';
function useToggle(initialValue = false) {
const [on, setOn] = useState(initialValue);
function toggle() {
setOn((current) => !current);
}
return [on, toggle];
}
function ToolCard({ name, details }) {
const [showDetails, toggleDetails] = useToggle();
return (
<li>
{name}{' '}
<button aria-expanded={showDetails} onClick={toggleDetails}>
{showDetails ? 'Hide details' : 'Show details'}
</button>
{showDetails && <p>{details}</p>}
</li>
);
}
function App() {
return (
<ul>
<ToolCard name="Cordless drill" details="18V, two batteries, loan up to 7 days." />
<ToolCard name="Extension ladder" details="Extends to 6 m, loan up to 3 days." />
</ul>
);
}
Only the drill’s details open; the ladder’s button still says “Show details”. If you want two components to see the same value, a hook alone won’t do it. Lift the state to a common parent (Lesson 5) or put it in a context (Lesson 11), and use the hook to read it, as useBasket did in the previous lesson.
Is useToggle worth having? Barely. It saves one line per use. I include it because it is small enough to see the mechanics; in a real project, extract a hook when it hides real complexity (an effect with cleanup, a race condition, error handling) or when the same few hooks travel together in several places.
The rules of hooks
Custom hooks follow the same two rules as the built-in ones:
- Call hooks only at the top level of a component or a custom hook: not inside
ifstatements, loops, nested functions, or after an earlyreturn. - Call hooks only from React components or other custom hooks, never from ordinary functions, event handlers or class components.
The reason is how React keeps track of state. It doesn’t know your variable names; it remembers hooks by the order in which a component calls them on each render: first useState, second useEffect, and so on. If a hook is called only sometimes, the order shifts and React hands one hook’s state to another. This code breaks the rule:
function ToolCard({ tool }) {
if (tool.status !== 'available') {
return <p>{tool.name} is not available</p>;
}
// Wrong: this hook is skipped whenever the early return above runs.
const { nextFreeDate } = useAvailability(tool.id);
return <p>{tool.name}: free from {nextFreeDate}</p>;
}
If the tool’s status changes between renders, the component calls a different number of hooks than before and React throws an error. The fix is to call the hook first, unconditionally, and put the condition after it, or inside the hook: useAvailability(tool.status === 'available' ? tool.id : null), with the effect doing nothing for null.
The use prefix is how readers and tools recognise a hook. The official ESLint plugin, eslint-plugin-react-hooks (included in most React and Next.js project templates), checks these rules and the effect dependency lists for any function named useSomething. The flip side: don’t give the prefix to a function that calls no hooks. A plain formatLoanDays(days) helper is just a function and can be called anywhere, including inside conditions.
useDebouncedValue for the search box
Filtering the eight tools on every keystroke is instant. But when the search goes to a server in Part 2, sending a request for “d”, “dr”, “dri” and “dril” on the way to “drill” wastes work. Debouncing means waiting until the input has been still for a short time and only then using its value. The input itself must still update on every keystroke, or typing would feel broken, so the hook returns a second, delayed copy of the value:
import { useEffect, useState } from 'react';
const TOOLS = ['Cordless drill', 'Extension ladder', 'Orbital sander', 'Hedge trimmer', 'Tile cutter'];
function useDebouncedValue(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
function App() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 400);
const needle = debouncedQuery.trim().toLowerCase();
const visible = TOOLS.filter((name) => name.toLowerCase().includes(needle));
console.log('Filtering for "' + debouncedQuery + '"');
return (
<>
<label htmlFor="tool-search">Search tools</label>{' '}
<input id="tool-search" value={query} onChange={(e) => setQuery(e.target.value)} />
<p>Showing results for "{debouncedQuery}"</p>
<ul>{visible.map((name) => <li key={name}>{name}</li>)}</ul>
</>
);
}
Type “sander” quickly. The input shows every letter straight away, but the list and the “Showing results for” line only change about 400 milliseconds after you stop. In the console, the debounced value jumps straight from “” to “sander”. (You will also see each line logged more than once, because the component re-renders on every keystroke while debouncedQuery stays the same. Lesson 13 is about those renders.)
The cleanup function is what makes it work. Each keystroke changes value, so React runs the cleanup of the previous effect, which cancels the pending timer, and then starts a new one. Only the last timer survives long enough to fire. The same cleanup runs when the component unmounts, so a timer can never update a component that is gone. Remove the return () => clearTimeout(timer); line and type again: every intermediate value now arrives, one after another, and the debounce has stopped doing its job.
useAvailability(toolId)
Now the hook from the start of the lesson. It takes a tool id and returns whether the answer is still loading and, once it has arrived, the next free date. It keeps the ignore flag from Lesson 9, so a slow answer for a tool the member has already moved away from is thrown away:
import { useEffect, useState } from 'react';
function fetchAvailability(toolId) {
return new Promise((resolve) => {
setTimeout(() => resolve({ toolId, nextFreeDate: '2026-12-01' }), 600);
});
}
function useAvailability(toolId) {
const [result, setResult] = useState(null);
useEffect(() => {
let ignore = false;
fetchAvailability(toolId).then((data) => {
if (!ignore) setResult(data);
});
return () => {
ignore = true;
};
}, [toolId]);
const loading = result === null || result.toolId !== toolId;
return { loading, nextFreeDate: loading ? null : result.nextFreeDate };
}
const TOOLS = [
{ id: 't2', name: 'Extension ladder' },
{ id: 't5', name: 'Pressure washer' },
];
function App() {
const [toolId, setToolId] = useState('t2');
const { loading, nextFreeDate } = useAvailability(toolId);
const tool = TOOLS.find((t) => t.id === toolId);
return (
<>
<div style={{ display: 'flex', gap: 8 }}>
{TOOLS.map((t) => <button key={t.id} onClick={() => setToolId(t.id)}>{t.name}</button>)}
</div>
<p>{tool.name}: {loading ? 'checking availability…' : 'next free on ' + nextFreeDate}</p>
</>
);
}
It shows “Extension ladder: checking availability…” and, just over half a second later, “next free on 2026-12-01”. Press Pressure washer and the text switches straight back to “checking availability…” for the new tool. Notice that the hook never sets a loading state. It stores the last answer together with the id it belongs to, and derives loading during render: if the stored answer is for a different tool, we are still waiting. That avoids an extra render and makes it impossible to show the ladder’s date next to the washer’s name.
Our fake API never fails. A real one does, so a production version would also catch the error and return it, for example { loading, nextFreeDate, error }, and every component using the hook would get error handling for free. That is the real payoff of a hook: fix or improve the logic once, and every caller benefits. For data that many components request, a data-fetching library or, in Next.js, Server Components (Lesson 24) take over most of this work.
useLocalStorage for the basket
Members lose their basket when they reload the page, because state lives in memory. The browser’s localStorage keeps small strings per site across reloads. A useLocalStorage hook can look exactly like useState to its caller, while reading the starting value from storage and writing every change back. Storage is not always available: private browsing modes, browser settings and sandboxed frames can block it, and reading it then throws. The live editor on this page is one of those sandboxes, so the hook must catch errors and carry on in memory:
import { useEffect, useState } from 'react';
function readStored(key, fallback) {
try {
const saved = window.localStorage.getItem(key);
return saved === null ? fallback : JSON.parse(saved);
} catch {
return fallback; // Blocked storage or a corrupt value.
}
}
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => readStored(key, initialValue));
const [persisted, setPersisted] = useState(true);
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
setPersisted(true);
} catch {
setPersisted(false); // Keep working in memory only.
}
}, [key, value]);
return [value, setValue, persisted];
}
function App() {
const [basket, setBasket, persisted] = useLocalStorage('tool-library-basket', []);
function add(name) {
setBasket((current) => (current.includes(name) ? current : [...current, name]));
}
return (
<>
<button onClick={() => add('Cordless drill')}>Add cordless drill</button>{' '}
<button onClick={() => add('Tile cutter')}>Add tile cutter</button>
<p>Basket: {basket.length ? basket.join(', ') : 'empty'}</p>
<p>{persisted ? 'Saved on this device.' : 'Storage is unavailable here, so the basket is kept until you reload.'}</p>
</>
);
}
In the live editor you will see “Storage is unavailable here”, and adding tools still works: the basket simply isn’t kept after a reload. Paste the same code into a normal React project and it says “Saved on this device.” and remembers the basket across reloads. The caller’s code is identical either way, which is the point of hiding this inside a hook.
Some details worth noticing. The initial read uses the function form of useState, so storage is read once, on the first render, not on every render. Writing happens in an effect, because it synchronises React state with something outside React (Lesson 9). Values go through JSON.stringify and JSON.parse, since storage only holds strings, and a corrupt value falls back to the default instead of crashing the page. The third return value tells the component whether saving works, so it can be honest with the member.
Limits to keep in mind: two components calling useLocalStorage('tool-library-basket', []) do not share live state (each call has its own useState, as in the previous section), and another browser tab won’t see changes until it reloads. Never keep anything sensitive in localStorage; any script on the page can read it. And in Next.js a component also renders on the server, where there is no localStorage, so reading it during the first render needs a different approach; Part 2 covers that.
Designing and testing a hook
A hook is an API that other developers (including you, later) will call without reading its insides. A few habits keep it pleasant:
- Name it after what it gives you, not how.
useAvailability(toolId)says what you get;useFetchWithIgnoreFlagdescribes the plumbing. - Keep inputs and outputs small. Take the values it needs as arguments. Return an array when there are two values that callers will want to name freely, like
useStateanduseLocalStorage; return an object when there are more, like{ loading, nextFreeDate, error }, so callers pick fields by name. - Do one job. A
useCataloguehook that searches, sorts, paginates and saves the basket is a component’s worth of logic in disguise. Several small hooks compose better. - Clean up everything it starts. Timers, listeners and subscriptions created in the hook’s effects must be removed in their cleanup, so the hook is safe however often its inputs change and whenever its component unmounts.
To test a hook, test it the way it is used. Often the simplest way is a test of a small component that uses it. React Testing Library also has a renderHook helper that calls a hook inside a throwaway component and gives you its return value. For useDebouncedValue, use your test runner’s fake timers to move time forward instead of really waiting, and check both cases: the value doesn’t change before the delay, and it does after. Lesson 49 covers testing in depth.
Common mistakes
- Expecting a hook to share state. Two components call
useLocalStorage('basket', [])and are surprised they disagree. Each call is separate. Share through a common parent or a context, and keep the hook inside the provider. - Calling a hook conditionally. A hook after an early
return, or inside anif, works until the condition changes and then throws. The ESLint plugin flags it; in review, check that everyuse…call sits at the top of the function. - Missing cleanup in a hook’s effect. A debounce without
clearTimeoutor a listener withoutremoveEventListener. The bug shows up in every component that uses the hook. Look for everysetTimeout,setIntervalandaddEventListenerin a hook and find its matching cleanup. - Using the
useprefix for a plain function.useFormatDatethat calls no hooks misleads readers and makes the linter apply hook rules where they aren’t needed. Name itformatDate. - A hook for one line. Wrapping a single
useStateinuseBasketOpenadds a file and a name to learn without hiding any complexity. Extract when there is real logic to hide or repeat.
Build it: useDisclosure
On phones the library’s header collapses into a Menu button, and the catalogue has a Filters panel that opens the same way. Both need the same behaviour: a button toggles the panel, the button’s aria-expanded reflects whether it is open, and pressing Escape closes it. The starter has this logic written inline for the menu only. Extract a useDisclosure() hook that returns { isOpen, open, close, toggle } and closes on Escape, then use it for both the menu and the filters panel. Opening one must not open the other.
import { useEffect, useState } from 'react';
function App() {
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
if (!menuOpen) return;
function onKeyDown(event) {
if (event.key === 'Escape') setMenuOpen(false);
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [menuOpen]);
return (
<>
<button aria-expanded={menuOpen} aria-controls="menu-panel" onClick={() => setMenuOpen(!menuOpen)}>
Menu
</button>
{menuOpen && (
<ul id="menu-panel">
<li>Catalogue</li>
<li>My loans</li>
</ul>
)}
{/* Add a Filters button and panel here, using the same hook. */}
</>
);
}
Hint: move the useState and the useEffect into useDisclosure unchanged, renaming menuOpen to isOpen. To test Escape in the editor, click inside the result first so it has keyboard focus.
Show a sample solution
import { useEffect, useState } from 'react';
function useDisclosure(initiallyOpen = false) {
const [isOpen, setIsOpen] = useState(initiallyOpen);
useEffect(() => {
if (!isOpen) return;
function onKeyDown(event) {
if (event.key === 'Escape') setIsOpen(false);
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [isOpen]);
return {
isOpen,
open: () => setIsOpen(true),
close: () => setIsOpen(false),
toggle: () => setIsOpen((current) => !current),
};
}
function App() {
const menu = useDisclosure();
const filters = useDisclosure();
return (
<>
<button aria-expanded={menu.isOpen} aria-controls="main-menu" onClick={menu.toggle}>Menu</button>{' '}
<button aria-expanded={filters.isOpen} aria-controls="filters" onClick={filters.toggle}>Filters</button>
{menu.isOpen && (
<ul id="main-menu">
<li>Catalogue</li>
<li>My loans</li>
</ul>
)}
{filters.isOpen && (
<div id="filters">
<label><input type="checkbox" /> Available now</label>
</div>
)}
</>
);
}
Press Menu and only the menu opens; the filters keep their own state because each useDisclosure() call has its own useState. The Escape listener only exists while a panel is open and is removed by the cleanup when it closes or the component unmounts, so there is never a stray listener. Returning an object lets callers write menu.toggle and filters.isOpen, which reads clearly when one component uses the hook twice.
Check your understanding
Answer each question in a sentence or two before opening the answer.
1. The header’s basket badge and the basket page both call useLocalStorage(‘tool-library-basket’, []). Adding a tool on the basket page doesn’t update the badge until you reload. Why?
Each call to the hook has its own useState. Both read the same stored value on their first render, but after that they are independent copies, and writing to storage doesn’t re-render the other component. Keep one basket in a provider (Lesson 11) that uses useLocalStorage once, and let both components read it from the context.
2. Someone removes the cleanup from useDebouncedValue. What changes, and how would you catch it in a test?
Every keystroke’s timer now fires, so the debounced value steps through every intermediate value (“s”, “sa”, “san”…) shortly after each one was typed, and the expensive work runs for each of them. A test with fake timers catches it: change the value several times within the delay, advance time past the delay, and assert that the work ran once, with the final value only.
3. Why can’t you write if (query.length > 2) { results = useDebouncedValue(query, 400); }?
React matches hooks to their state by call order. When the query grows from two to three characters, the component suddenly calls more hooks than on the previous render, so the order no longer lines up and React throws an error. Call the hook unconditionally at the top and apply the length check to its result instead.
Key takeaways
- A custom hook is a function whose name starts with
useand which calls other hooks. It reuses logic; each call has its own state. - Hooks run in the same order on every render: only at the top level of components and custom hooks, never conditionally.
- Hooks that start timers, listeners or requests must clean them up, and should handle failure (such as blocked storage or a failed request) so every caller gets it right.
- Name hooks for what they return, keep their inputs and outputs small, and extract one only when it hides real logic.
Priority: should know. You should be able to spot logic worth extracting, write a hook with correct cleanup and explain why two components using it don’t share state. See Reusing Logic with Custom Hooks and Rules of Hooks in the React documentation.
