06 — The Core Hooks — useState, useReducer, useEffect, and the Ref/Memo Family
An endless API list was how the hooks family felt until I matched each one to its job. The framing that consolidated it: hooks are primitives, each with one job. [1] useState and useReducer hold state. useEffect synchronizes with the outside world. useRef, useMemo, and useCallback are tools for the cases where those two aren't enough — escaping the render cycle, caching expensive work, and stabilizing function identity. Once I matched each hook to its job, the list stopped feeling arbitrary.
The Rules of Hooks
Before the individual hooks, the two rules that govern all of them [2]:
- Call hooks at the top level only — never inside loops, conditions, or nested functions. React relies on call order to match each hook to its state, so conditional hooks break that accounting.
- Call hooks from React functions — a component, or a custom hook. Not from a regular utility.
These rules are why hooks exist at all — they let React track per-component state without me passing around instances. Every lint failure pointing at a conditional hook is React protecting me from corrupted state.
useState: the basic state primitive
useState adds local state to a function component [3]. It returns the current value and a setter; calling the setter schedules a re-render with the new value. Three details that I had to internalize:
- The setter is the only way to update. Direct mutation does nothing visible.
- Use the callback form when the next value depends on the previous (setCount(c => c + 1)) — updates are batched and may be async.
- State is per-instance and persists across renders, but resets when the component unmounts (or its position in the tree changes — see the notes on reconciliation).
const [open, setOpen] = useState(false);useState is the default for independent, simple values. When state gets tangled — multiple fields that update together, or a value whose next state depends on a complex previous state — that's the signal to reach for useReducer.
useReducer: state with transitions
useReducer is the alternative to useState for state that follows clear transitions [4]. I provide a reducer (state, action) => newState and a dispatch function; components call dispatch({ type: 'increment' }) rather than setCount(c => c + 1). The state logic lives in one testable function instead of scattered across handlers.
function reducer(state, action) {
switch (action.type) {
case 'increment': return { count: state.count + 1 };
case 'reset': return { count: 0 };
default: return state;
}
}
const [state, dispatch] = useReducer(reducer, { count: 0 });If you've used Redux, this is exactly that pattern, locally. The signal to switch from useState to useReducer: when one user action needs to update several related pieces of state at once, or when the next-state logic is more than a one-liner. A form with many interdependent fields, a multi-step wizard, a game's turn state — these all read more clearly as reducers.
useEffect: synchronizing with the outside world
useEffect runs side effects after render — fetching data, subscribing, reading from localStorage, integrating a non-React library [5]. The model is "synchronize with an external system": the effect sets things up, the cleanup tears them down, and React re-runs the pair whenever the dependencies change. The notes on the lifecycle cover this in depth; the key points for these notes:
- Always provide an explicit dependency array.
- Always pair setup with cleanup.
- If the effect body doesn't touch anything outside React, you probably don't need it — derive state during render instead.
useRef: values that survive renders without triggering them
useRef returns a mutable object whose .current persists across renders [6]. Changing .current does not trigger a re-render, which is exactly the point. Two jobs:
- Reaching into the DOM — <input ref={inputRef} /> gives me the actual node for imperative focus, measurement, or third-party libraries.
- Storing a value that shouldn't cause renders — a debounce timer id, an "is mounted" flag, a cached value that React shouldn't watch.
const timerRef = useRef(null);
timerRef.current = setTimeout(...); // changing this never re-rendersThe rule: if the UI should change when the value changes, it's state, not a ref.
useMemo and useCallback: caching for a reason
useMemo caches the result of a computation; useCallback caches a function definition [7][8]. They are not free — every memoization adds bookkeeping, and the deps comparison itself costs something. They earn their keep in specific situations:
- Expensive computation. A calculation that takes meaningful time and runs on every render — memoize it.
- Referential equality for props. React compares props by reference — is it the same object, not just equal contents. If I pass an object, array, or function as a prop to a memoized child (React.memo), the child still re-renders unless that prop's identity is stable. useMemo/useCallback stabilize it.
const sorted = useMemo(() => heavySort(items), [items]); // cache the result
const onSelect = useCallback(id => setActive(id), []); // stable function identityThe framing I keep coming back to (from Kent C. Dodds and Josh Comeau): don't memoize defensively. [7][8] Profile first. Most renders are cheap, and premature useMemo adds overhead for no gain. Reach for it when a profiler run shows real cost.
useContext: data that doesn't fit the prop chain
useContext reads a value from a React Context provider without prop-drilling — threading the value through every component in between [9]. The model: a Provider at the top of the tree declares a value, and any descendant can useContext(MyContext) to read it directly. Theme, locale, the current authenticated user — these are the canonical cases.
const theme = useContext(ThemeContext);Two caveats I learned the hard way:
- Every provider value change re-renders every consumer, even ones that only read part of the value. Splitting contexts (one per slice) or moving to an external store avoids the re-render storm on large trees.
- Context is not a state management library. It's a dependency-injection mechanism — a way to hand a value down to any component that asks, without passing it through the layers in between. For frequently-changing application state, a dedicated store (Zustand, Jotai) is the better tool — covered in the notes on state management.
Custom hooks: extracting reusable logic
When the same hook combination shows up in several components, I extract it into a custom hook — a plain function whose name starts with use [10]. Custom hooks are how hooks replace the old HOC and render-prop patterns: logic moves into a function I can call from any component.
function useWindowSize() {
const [size, setSize] = useState({ w: 0, h: 0 });
useEffect(() => {
const onResize = () => setSize({ w: innerWidth, h: innerHeight });
window.addEventListener('resize', onResize);
onResize();
return () => window.removeEventListener('resize', onResize);
}, []);
return size;
}The two Rules of Hooks apply here too — a custom hook can call other hooks, but only at its top level.
How I use this
My default ladder for any new component is to reach for the simplest hook that fits and only escalate when there's a concrete reason. useState for simple values; useReducer the moment state updates get interdependent. useEffect only for genuine external synchronization, always with explicit deps and cleanup. useRef for DOM access and render-immune values. useMemo/useCallback only after a profiler run shows a cost — never defensively. useContext for genuinely global, slowly-changing values (theme, locale, auth session), and a real state store once the data changes often enough that Context's re-renders hurt. And whenever the same hook pattern repeats across components, it becomes a custom hook — that's where the real leverage of the hooks model shows up.
References
[1] React team, "Hooks reference," react.dev, 2024. [Online]. Available: https://react.dev/reference/react
[2] React team, "Rules of Hooks," react.dev, 2024. [Online]. Available: https://react.dev/reference/rules/rules-of-hooks
[3] React team, "useState," react.dev, 2024. [Online]. Available: https://react.dev/reference/react/useState
[4] React team, "useReducer," react.dev, 2024. [Online]. Available: https://react.dev/reference/react/useReducer
[5] React team, "useEffect," react.dev, 2024. [Online]. Available: https://react.dev/reference/react/useEffect
[6] React team, "useRef," react.dev, 2024. [Online]. Available: https://react.dev/reference/react/useRef
[7] J. Comeau, "useMemo and useCallback," joshwcomeau.com, 2023. [Online]. Available: https://www.joshwcomeau.com/react/usememo-and-usecallback/
[8] K. C. Dodds, "useMemo and useCallback," kentcdodds.com, 2021. [Online]. Available: https://kentcdodds.com/blog/usememo-and-usecallback
[9] React team, "useContext," react.dev, 2024. [Online]. Available: https://react.dev/reference/react/useContext
[10] React team, "Reusing logic with custom hooks," react.dev, 2024. [Online]. Available: https://react.dev/learn/reusing-logic-with-custom-hooks
Knowledge check · Question 1 of 5
When does useState vs useReducer become the right call?
Comments
Leave a Comment
You must be signed in to comment
0 Comments
No comments yet. Be the first to comment!