Components share markup by composing other components. What about sharing logic: the same combination of state and effects, repeated in several components? You could copy and paste it, but then a fix needs making in many places. React's answer is the custom hook: a function that you write, which uses other hooks inside, and which any component can call.
The idea: extract and name
Here is a small piece of logic: a boolean you can switch on and off. Without a hook, every component that needs it writes the same lines:
import { useState } from "react";
function App() {
const [menuOpen, setMenuOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
return (
<div>
<button className="menu" onClick={() => setMenuOpen(!menuOpen)}>Menu</button>
<button className="help" onClick={() => setHelpOpen(!helpOpen)}>Help</button>
<p>Menu is {menuOpen ? "open" : "closed"}, help is {helpOpen ? "open" : "closed"}.</p>
</div>
);
}
export default App;MenuHelp
Menu is open, help is open.Extract the repeated part into a function whose name starts with use:
import { useState } from "react";
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = () => setOn(value => !value);
return [on, toggle];
}
function App() {
const [menuOpen, toggleMenu] = useToggle();
const [helpOpen, toggleHelp] = useToggle(true);
return (
<div>
<button className="menu" onClick={toggleMenu}>Menu</button>
<button className="help" onClick={toggleHelp}>Help</button>
<p>Menu is {menuOpen ? "open" : "closed"}, help is {helpOpen ? "open" : "closed"}.</p>
</div>
);
}
export default App;MenuHelp
Menu is open, help is closed.useToggle is an ordinary function that calls useState and returns what the component needs. After the test clicks, the menu is open and the help (which started open) is closed. The component now reads as a statement of intent: "a toggle for the menu, a toggle for help". Notice the pieces:
- The name starts with
use. This is how React and the linter know that the function calls hooks, so the rules of hooks must be applied to it. It is a convention enforced by tooling. - It can accept arguments and return anything: an array, an object, a single value, a function.
- It is called at the top level of a component, like any hook.
Logic is shared, state is not
This is the point that surprises people. Calling a custom hook twice, in one component or two, gives two independent copies of its state. A hook shares the code, not the data.
import { useState } from "react";
function useCounter(step = 1) {
const [count, setCount] = useState(0);
const increase = () => setCount(c => c + step);
const reset = () => setCount(0);
return { count, increase, reset };
}
function Counter({ label, step }) {
const { count, increase } = useCounter(step);
return <button onClick={increase}>{label}: {count}</button>;
}
function App() {
return (
<ul>
<li><Counter label="Ones" step={1} /></li>
<li><Counter label="Tens" step={10} /></li>
</ul>
);
}
export default App;Ones: 2
Tens: 10The first counter was clicked twice (1, 2), the second once (10), and neither affected the other. If you want components to share one value, lift the state up or use context; a hook does not do that by itself.
A hook with an effect: useDebounce
Debouncing waits until a value stops changing before acting on it. It is how a search box avoids sending a request for every keystroke. The hook wraps a state, an effect and a cleanup:
import { useEffect, useState } from "react";
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id); // typing again cancels the pending update
}, [value, delay]);
return debounced;
}
function App() {
const [text, setText] = useState("");
const search = useDebounce(text, 300);
return (
<div>
<input value={text} onChange={e => setText(e.target.value)} placeholder="Search" />
<p>Typed: {text}</p>
<p>Would search for: {search}</p>
</div>
);
}
export default App;Typed: react
Would search for: reactEach keystroke starts a 300 ms timer and cancels the previous one, so "Would search for" only changes after typing pauses. The component that uses it needs none of this machinery. In a real search you would pass search to a fetch effect.
A hook for the browser: useLocalStorage
A common hook keeps a state value in localStorage so it survives page reloads. Because the lesson editor runs in a sandbox that blocks storage, the version below falls back to plain memory; in a real app, localStorage works normally:
import { useEffect, useState } from "react";
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const saved = window.localStorage.getItem(key);
return saved !== null ? JSON.parse(saved) : initialValue;
} catch {
return initialValue; // storage blocked or invalid JSON
}
});
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
// ignore: private mode, quota exceeded, or a sandboxed frame
}
}, [key, value]);
return [value, setValue];
}
function App() {
const [visits, setVisits] = useLocalStorage("visits", 0);
return <button onClick={() => setVisits(v => v + 1)}>Visits: {visits}</button>;
}
export default App;Visits: 2Three habits are on display: reading the initial value with a lazy initialiser (a function passed to useState, run only once), writing back in an effect whenever the value changes, and protecting against errors with try/catch, since storage can be unavailable. Components that use the hook look the same as if they used useState. (Remember that the hook above is for the browser; code that runs on a server has no window.)
A hook for data: useFetch
The data-fetching lesson built a loading, error and success pattern with an ignore flag. That belongs in a hook, so it is written once. This version takes a function that returns a promise (a fake API here, so the example is predictable):
import { useEffect, useState } from "react";
function useAsync(load, dependencies) {
const [state, setState] = useState({ status: "loading", data: null, error: null });
useEffect(() => {
let ignore = false;
setState({ status: "loading", data: null, error: null });
load()
.then(data => { if (!ignore) setState({ status: "success", data, error: null }); })
.catch(error => { if (!ignore) setState({ status: "error", data: null, error }); });
return () => { ignore = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies);
return state;
}
const fakeApi = id => new Promise((resolve, reject) =>
setTimeout(() => (id === 3 ? reject(new Error("Not found")) : resolve({ id, name: "Item " + id })), 100));
function App() {
const [id, setId] = useState(1);
const { status, data, error } = useAsync(() => fakeApi(id), [id]);
return (
<div>
<button className="b" onClick={() => setId(id + 1)}>Next (id {id})</button>
{status === "loading" && <p>Loading...</p>}
{status === "success" && <p>{data.name}</p>}
{status === "error" && <p role="alert">Error: {error.message}</p>}
</div>
);
}
export default App;Next (id 2)
Item 2The test pressed the button once (id 2) and let the request finish. This hook takes a dependency list from the caller, which is why the lint comment appears: the linter cannot see through the dependencies parameter. That limitation is one reason real projects prefer a library such as TanStack Query over a hand-made useFetch. Still, writing this once shows what such libraries do.
Rules and conventions
- Start the name with
use, followed by a capital letter:useToggle, nottoggleorusetoggle. - Call hooks at the top level, never in loops, conditions or handlers, also inside your custom hooks.
- Only call hooks from components or other hooks.
- Return what callers need, no more: an array for two or three related values (like
useState), an object when there are many or when names help. - A custom hook should have one clear purpose, described by its name. Avoid "lifecycle" hooks such as
useMount; they hide what the effect really synchronises. - Put hooks in their own files (
src/hooks/useToggle.js) once you reuse them. - Test them through a component, or with a testing helper such as
renderHookfrom React Testing Library.
When to write one
Extract a custom hook when:
- the same state-and-effect combination appears in more than one component,
- a component is hard to read because of a block of hook code that does one nameable job,
- you want to hide a browser or library API behind a simple interface (
useOnlineStatus,useWindowSize).
Do not extract a hook just to wrap a single useState without adding meaning, and do not worry about hooks for logic used once; plain code in the component is fine. Before writing a hook, check whether a library already has it: popular collections (such as usehooks-ts) offer well-tested versions of most of the examples above.
Common mistakes
- Naming the function without
use, so the linter cannot check it. - Expecting two components that call the same hook to share state.
- Calling a hook inside a condition, loop or an event handler.
- Returning a new object or function on every render and passing it as a dependency elsewhere, which re-runs effects (see the memoization lesson).
- Writing a "do everything" hook that mixes unrelated concerns.
- Forgetting that code like
windoworlocalStoragefails during server rendering and in sandboxed frames.
Exercise
- Write
useWindowWidth()that returns the current width and updates on resize, with a proper cleanup. Use it in two components. - Add a
useCounteroptionmaxso the counter stops at that value. - Write
usePrevious(value)that returns the value from the previous render (hint: a ref and an effect). - Use
useDebouncewith the fetching example from the previous lesson to search todos without sending a request for each key.
Key points
- A custom hook is a function whose name starts with use and which calls other hooks; it lets components share logic, not state.
- Each component that calls a custom hook gets its own independent state and effects.
- Extract a hook when the same combination of state and effects appears in several components, or when it gives a clear name to a block of logic.
- Custom hooks follow the rules of hooks: call them at the top level of a component or another hook.