Articles › React
React Hooks Explained: useState, useEffect, useRef, useMemo and More
A practical tour of the essential React hooks — how they work, when to use each one, and the rules and mistakes every React developer should know.
Hooks transformed React. Since React 16.8, function components can hold state, run side effects and access context — everything that once required classes. Today hooks are React. This guide walks through the hooks you'll use every day, with practical examples and the mental model behind each one.
The two rules of hooks
Before anything else, remember the rules React relies on:
- Only call hooks at the top level of a component or custom hook — never inside conditions, loops or nested functions.
- Only call hooks from React functions — components and custom hooks, not regular utility functions.
React identifies each hook by its call order. If a hook is skipped in one render, every hook after it receives the wrong state. The eslint-plugin-react-hooks package enforces these rules automatically — install it.
useState: local state
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount((c) => c + 1)}>
Clicked {count} times
</button>
);
}
Key points:
- Calling the setter schedules a re-render;
countdoesn't change in the current render. - When the next state depends on the previous one, use the updater function
setCount(c => c + 1). Several updates in the same event are then applied correctly. - State is replaced, not merged. For objects, copy the previous value:
setForm((prev) => ({ ...prev, email: value }));
- For expensive initial values, pass a function:
useState(() => loadFromStorage()). It runs only on the first render.
useEffect: synchronizing with the outside world
useEffect runs after React has updated the DOM. Use it to synchronize your component with something outside React: network requests, subscriptions, timers, the document title, third-party widgets.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false;
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
if (!cancelled) setUser(data);
});
return () => {
cancelled = true; // cleanup: ignore stale responses
};
}, [userId]);
return user ? <h2>{user.name}</h2> : <p>Loading…</p>;
}
The dependency array controls when the effect re-runs:
| Dependencies | Runs |
|---|---|
| none | after every render |
[] |
once after mount (cleanup on unmount) |
[a, b] |
after mount and whenever a or b changes |
The function returned from the effect is the cleanup. React calls it before re-running the effect and when the component unmounts — perfect for removing event listeners, clearing timers or aborting requests.
Tip: If something can be calculated from props or state during rendering, it doesn't belong in an effect. Derive it directly. Effects are an escape hatch, not a data-flow tool.
useRef: values that persist without re-rendering
useRef returns a mutable object { current } that survives between renders. Changing it does not trigger a re-render. Two common uses:
// 1) Access a DOM element
function SearchBox() {
const inputRef = useRef(null);
useEffect(() => inputRef.current.focus(), []);
return <input ref={inputRef} />;
}
// 2) Keep a mutable value (timer id, previous value, instance)
function Stopwatch() {
const intervalRef = useRef(null);
const start = () => {
intervalRef.current = setInterval(() => console.log("tick"), 1000);
};
const stop = () => clearInterval(intervalRef.current);
return (
<>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</>
);
}
Rule of thumb: if the value is shown on screen, use state; if it's just remembered for later, use a ref.
useMemo and useCallback: caching between renders
useMemo caches the result of a calculation; useCallback caches a function.
function ProductList({ products, query }) {
const filtered = useMemo(
() => products.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())),
[products, query]
);
const handleSelect = useCallback((id) => {
console.log("selected", id);
}, []);
return filtered.map((p) => <ProductRow key={p.id} product={p} onSelect={handleSelect} />);
}
They matter in two situations:
- The calculation is genuinely expensive (filtering thousands of items, heavy formatting).
- You pass objects or functions to memoized children (
React.memo) or into another hook's dependency array, and need a stable reference.
Don't wrap everything by default — memoization has its own cost. Measure with the React DevTools Profiler first. (The React Compiler can now apply many of these optimizations automatically.)
useContext: avoiding prop drilling
const ThemeContext = createContext("light");
function App() {
const [theme, setTheme] = useState("dark");
return (
<ThemeContext.Provider value={theme}>
<Toolbar onToggle={() => setTheme((t) => (t === "dark" ? "light" : "dark"))} />
</ThemeContext.Provider>
);
}
function ThemedButton() {
const theme = useContext(ThemeContext);
return <button className={`btn btn--${theme}`}>Save</button>;
}
Every component that reads a context re-renders when its value changes. Context is excellent for data that changes rarely — theme, language, current user — and not ideal for rapidly changing values.
useReducer: complex state transitions
When several pieces of state change together, a reducer keeps the logic in one predictable place:
function reducer(state, action) {
switch (action.type) {
case "added":
return { ...state, items: [...state.items, action.item] };
case "removed":
return { ...state, items: state.items.filter((i) => i.id !== action.id) };
case "cleared":
return { ...state, items: [] };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function Cart() {
const [state, dispatch] = useReducer(reducer, { items: [] });
// dispatch({ type: "added", item })
}
Reducers are pure functions, so they're easy to test without rendering anything.
Other hooks worth knowing
useId— generates stable unique ids for accessibility attributes (htmlFor,aria-describedby).useLayoutEffect— likeuseEffectbut runs before the browser paints. Use it to measure the DOM and avoid flicker (animations, tooltips).useTransition— marks an update as non-urgent so typing stays responsive while a heavy list re-renders.useDeferredValue— renders a "lagging" version of a value for expensive UI.useSyncExternalStore— subscribes to external stores (browser APIs, state libraries) safely.
Common mistakes
- Missing dependencies — reading a prop inside an effect without listing it leads to stale values. Trust the linter.
- Infinite loops — setting state inside an effect that depends on that same state.
- Objects in dependency arrays —
{}created during render is a new object every time, so the effect runs every render. - Fetching without cleanup — fast navigation can show the response of an old request.
- Copying props into state — usually unnecessary; derive values instead.
Summary
useState stores what changes over time, useEffect synchronizes with the outside world, useRef remembers values without re-rendering, useMemo/useCallback cache work and references, useContext shares data across the tree, and useReducer organizes complex transitions. Follow the rules of hooks, keep effects minimal and derive whatever you can — that's the core of modern React.
FAQ
Why does my effect run twice in development?
In StrictMode, React mounts, unmounts and remounts components in development to help you find missing cleanups. It doesn't happen in production.
Should I use useEffect to fetch data?
It works, but libraries such as TanStack Query handle caching, deduplication, retries and race conditions for you. For real applications they're usually the better choice.
When should I create a custom hook?
When two or more components share the same stateful logic — fetching, form handling, media queries, local storage. Custom hooks share logic, not state.