The Repetition That Hooks Remove
By now you can share markup between components — that is what components are for. What you cannot yet share is behaviour. Suppose three screens each read a value from localStorage on mount and write it back whenever it changes. That is six or seven lines of useState and useEffect, copied three times, with three chances to get the JSON parsing wrong and three places to fix when you do.
A custom hook solves exactly this. It is an ordinary JavaScript function whose name begins with use and which is allowed to call other hooks. You move the stateful logic into it, and each component calls one line instead of seven.
There is no new syntax to learn and nothing to register. If you can write a function, you can write a custom hook — the only genuinely new things are the naming rule and the rules about where hooks may be called, both covered below.
// Before: the same logic pasted into every component that needs it
function ThemeSwitcher() {
const [theme, setTheme] = useState(() => {
const saved = localStorage.getItem('theme');
return saved ? JSON.parse(saved) : 'light';
});
useEffect(() => {
localStorage.setItem('theme', JSON.stringify(theme));
}, [theme]);
// ...
}
// After: one line
function ThemeSwitcher() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
// ...
} - Custom hooks are React's main answer to code reuse. Older React had patterns called higher-order components and render props for the same purpose; you will meet them in existing codebases, but a custom hook is simpler and is what you should write today.
Writing Your First One
Take the logic out of the component, put it in a function, and return whatever the component needs. That is the entire process. The function can take arguments, so useLocalStorage accepts the storage key and a fallback value, which makes it reusable rather than one-off.
The name must begin with use. This is not decoration: the lint rules that check hook usage identify hooks by that prefix, and without it they cannot tell that your function calls hooks internally, so genuine mistakes stop being reported. A function named getLocalStorage that calls useState is a bug waiting to happen.
What you return is your choice. Return an array when the two values are a natural pair and the caller will want to rename them — that is why useState itself returns an array, so you can have [name, setName] and [email, setEmail] in the same component. Return an object when there are several values with meaningful names, since object destructuring lets the caller take only what it needs and reads better than remembering positions.
Notice too that a custom hook can use other custom hooks. They compose exactly like functions, which is how larger ones such as useAuth or useCart get built out of smaller pieces.
import { useState, useEffect } 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; // corrupted JSON should not crash the app
}
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue]; // a pair -> return an array
}
export function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = () => setOn(v => !v);
return [on, toggle];
}
// Several named things -> return an object
export function useDisclosure(initial = false) {
const [isOpen, setIsOpen] = useState(initial);
return {
isOpen,
open: () => setIsOpen(true),
close: () => setIsOpen(false),
toggle: () => setIsOpen(v => !v)
};
}
// Using them
function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
const { isOpen, toggle } = useDisclosure();
// ...
} - Keep custom hooks in their own files under a
hooks/folder, one per file, named after the hook:hooks/useLocalStorage.js. They are as reusable as components and deserve the same treatment.
Hooks Share Logic, Not State
This is the misunderstanding that causes the most confusion, so it is worth stating plainly. Calling the same custom hook from two components does not connect them. Each call creates its own independent state, exactly as two separate useState calls would.
Think of a custom hook as a recipe, not a shared jar. When a component calls useToggle(), the useState inside runs for that component and belongs to it. Another component calling useToggle() gets its own. Two form fields both using useDebounce debounce independently, which is precisely what you want.
So if you need two components to share the same value — a shopping cart, the signed-in user, a theme the whole app reacts to — a custom hook is not the tool. Lift the state into a common parent, or use Context, which is the next lesson. A custom hook is very often the nice interface you put in front of Context, but it is not by itself a way to share data.
// Two components, two completely separate counters
function Likes() {
const [count, setCount] = useCounter(0); // its own state
return <button onClick={() => setCount(count + 1)}>👍 {count}</button>;
}
function Shares() {
const [count, setCount] = useCounter(0); // a different, unrelated state
return <button onClick={() => setCount(count + 1)}>↗ {count}</button>;
}
// Clicking Likes does not change Shares. If you wanted one shared number,
// it has to live somewhere both components can see — a common parent, or Context. - The same is true of two calls in the same component.
const [a] = useToggle(); const [b] = useToggle();gives you two independent toggles, which is why hooks can be reused freely within one component.
The Rules of Hooks, and the Reason for Them
Two rules apply to every hook, built-in or custom. Call hooks only at the top level of a component or another hook — never inside an if, a loop, a nested function or after an early return. And call them only from React function components or from other hooks, never from a plain utility function or an event handler.
The reason is simple once you know how React stores hook data. React does not know the names of your variables. For each component it keeps an internal list of hook slots, and it fills them in the order the hooks are called: the first useState gets slot one, the second gets slot two, and so on. On the next render it walks the same list in the same order and hands back the matching values.
That mechanism only works if the order is identical every single time. Put a useState inside an if and on some renders the call happens and on others it does not, so every hook after it shifts by one slot. Your email state suddenly receives the value that belonged to name, or React throws Rendered fewer hooks than expected. This is not a defensive restriction — it is a direct consequence of the design that makes hooks so concise in the first place.
The fix is always the same shape: move the condition inside the hook rather than around it. Call useEffect unconditionally and put the if in its body. Call useState unconditionally and choose what to do with the value later. And add eslint-plugin-react-hooks to your project, which catches every violation of both rules before you run the code.
// BROKEN: the hook is behind a condition
function Profile({ userId }) {
if (!userId) return <p>Not signed in</p>; // early return BEFORE a hook
const [user, setUser] = useState(null); // skipped on some renders
// ...
}
// BROKEN: a hook inside a loop
fields.forEach(field => {
const [value, setValue] = useState(''); // count changes with the data
});
// FIXED: hooks first, always in the same order; conditions go inside
function Profile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
if (!userId) return; // condition inside the effect
loadUser(userId).then(setUser);
}, [userId]);
if (!userId) return <p>Not signed in</p>; // early return AFTER the hooks
return <h1>{user?.name}</h1>;
}
// FIXED: variable numbers of values go in one state object, not many hooks
const [values, setValues] = useState({}); - Call hooks at the top level of a component or a custom hook
- Never inside
if,for,while, or a nested function - Never after a conditional
return— move the return below the hooks - Only from components (capitalised) or from other
use…functions - Not from event handlers, class components, or ordinary utility functions
- Install
eslint-plugin-react-hooks— it enforces all of this automatically
- Rendered more hooks than during the previous render and Rendered fewer hooks than expected both mean exactly one thing: a hook call is behind a condition somewhere. Look for an
ifor an earlyreturnabove the hook that moved.
Hooks Worth Having in Every Project
A few custom hooks come up in nearly every application, and writing them yourself is the best way to get comfortable with the pattern. useDebounce is the most immediately useful: it delays a value so that a search box does not fire a request on every keystroke. Notice how it uses the effect's cleanup to cancel the pending timer whenever the value changes again — that is the whole debounce, in five lines.
useFetch wraps the loading, error and data trio you would otherwise write in every component that talks to a server, including the ignore flag that prevents an out-of-order response from overwriting a newer one. It is a good exercise, though for production most teams reach for TanStack Query or SWR, which add caching and refetching that a hand-written hook will not have.
The general shape to aim for: one hook, one concern, a clear name, and a return value the calling component can use without knowing how the hook works inside.
// Delay a value until the user stops typing
export function useDebounce(value, delay = 400) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id); // cancel the pending update
}, [value, delay]);
return debounced;
}
// The classic search box, now sending one request instead of twelve
function ProductSearch() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 400);
useEffect(() => {
if (!debouncedQuery) return;
searchProducts(debouncedQuery).then(setResults);
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
// Loading / error / data in one place
export function useFetch(url) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let ignore = false;
setIsLoading(true);
setError(null);
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`Request failed with ${res.status}`);
return res.json();
})
.then(json => { if (!ignore) setData(json); })
.catch(err => { if (!ignore) setError(err); })
.finally(() => { if (!ignore) setIsLoading(false); });
return () => { ignore = true; };
}, [url]);
return { data, error, isLoading };
} - If a function does not call any hook, it does not need to be one. A currency formatter or a validation routine is a plain utility function — putting
usein front of it only misleads the next reader and the lint rules.
When to Extract, and When to Wait
Extraction has a cost. A custom hook adds a file, a layer of indirection, and a name someone has to learn. Pulled out too early, before you know what varies, you end up with a hook that takes seven arguments and half of them are only used by one caller.
The honest signal is duplication that has already happened, at least twice, with the same shape. Two components with the same useState plus useEffect pattern and only the URL differing is a hook. Two components that both fetch things but handle them completely differently is not — it is two components that happen to both use the network.
A second, quieter reason to extract is readability rather than reuse. A component with sixty lines of effects, timers and event listeners before its JSX is hard to read even if nothing is repeated. Moving that machinery into useChatConnection(roomId) leaves a component that reads as a description of the screen, which is what a component should be. That is a perfectly good reason to write a hook used exactly once.
- Extract when the same stateful logic appears in two or more components
- Extract when a component's setup code drowns out its JSX
- Wait when you have only one caller and no clarity problem
- Keep one concern per hook —
useAuthanduseCart, notuseEverything - Name it for what it gives you, not how it works:
useOnlineStatus, notuseNavigatorEventListener - A function with no hooks in it should stay a plain function
- Custom hooks are also the tidiest way to hide a Context. The next lesson builds
useAuth()as a one-line wrapper arounduseContext, so that no component ever imports the context object directly.
