State & Effects — Cheat Sheet
React · 9 topics. Download the PDF or the Instagram carousel and share it.
useState — The Core of Everything
State is a snapshot for a given render, not a variable you read live. That one idea explains stale values, batching, and why you update with a function.
- ✓State is a snapshot per render — reading it right after setting gives the previous value
- ✓Use the functional updater whenever the next value depends on the previous one
- ✓React batches updates in the same tick into a single re-render, including inside promises and timeouts
- ✓Pass a function to useState for expensive initial values, and remember the initial value is only used on mount
- ✓Never mutate state — push, splice and sort change the data without changing the reference React compares
const [count, setCount] = useState(0)
function handleClick() {
setCount(count + 1)
console.log(count) // still 0 — this render's snapshot
}
// Two updates from the same snapshot collapse into one
function handleDouble() {
setCount(count + 1) // 0 + 1
setCount(count + 1) // 0 + 1 again -> ends at 1, not 2
}
// The functional updater reads the latest queued value
function handleDoubleFixed() {
setCount(c => c + 1) // 0 -> 1
setCount(c => c + 1) // 1 -> 2
}
// Rule of thumb: if the next value depends on the previous one,
// always use the function form.Structuring State — Where It Lives and What Belongs In It
Most React bugs are state in the wrong place or state that should not exist. Deciding both correctly removes whole categories of bug before they happen.
- ✓Anything derivable from existing state or props should be computed during render, not stored
- ✓Store an id rather than a copy of an object, so a refetch cannot leave a stale duplicate
- ✓Put state in the closest common ancestor of its readers — lifting too high re-renders the whole subtree
- ✓Filters, tabs and pagination belong in the URL, where they survive a refresh and can be shared
- ✓Replace independent booleans with one status value so impossible combinations cannot be represented
// Redundant — two sources of truth that can drift apart const [problems, setProblems] = useState([]) const [count, setCount] = useState(0) // derivable const [hasResults, setHasResults] = useState(false) // derivable const [selected, setSelected] = useState(null) // storing the whole object // Derived during render — cannot be stale, by construction const [problems, setProblems] = useState([]) const [selectedId, setSelectedId] = useState(null) const count = problems.length const hasResults = problems.length > 0 const selected = problems.find(p => p.id === selectedId) // Store the ID, not the object. If the object is refetched and // updated, a stored copy silently becomes the old version. // "But it recalculates every render" — filtering a few hundred rows // is microseconds. Measure before caching it with useMemo.
useEffect — Synchronising With the Outside World
An effect exists to synchronise your component with something outside React. It is not a lifecycle hook and not a place to put "code that runs after state changes".
- ✓An effect synchronises a component with an external system — it is not a lifecycle or a reaction hook
- ✓The dependency array declares what the setup reads; omitting a value captures a stale one forever
- ✓An object or function recreated each render is the usual cause of an infinite effect loop — depend on primitives
- ✓Effects run after paint; useLayoutEffect runs before paint and is only for measuring or positioning
- ✓Never silence react-hooks/exhaustive-deps — restructure the effect so the honest list is correct
useEffect(() => {
// setup — runs after the browser has painted
const es = new EventSource(`/api/jobs/${jobId}/progress`)
es.onmessage = (e) => setProgress(JSON.parse(e.data))
return () => es.close() // cleanup — before the next run, and on unmount
}, [jobId]) // dependencies
// The dependency array is not a trigger list. It is a claim:
// "this setup reads exactly these values." React re-runs the effect
// when any of them changed since last time.
// The three forms
useEffect(fn) // after EVERY render — almost always a mistake
useEffect(fn, []) // once on mount, cleanup on unmount
useEffect(fn, [a, b]) // when a or b changed
// Effects run after paint, so the user sees the render first.
// useLayoutEffect runs before paint — use it only to measure or
// position something the user must never see in the wrong place.Cleanup and Race Conditions
Every subscription needs a cleanup, and every fetch inside an effect needs to handle a response that arrived too late. Both are invisible in development and obvious to users.
- ✓Cleanup runs before every re-run of the effect and on unmount, not only when the component disappears
- ✓Anything started in an effect — timer, listener, socket, observer — must be stopped in its cleanup
- ✓Two requests from a changing dependency can resolve out of order; an ignore flag or AbortController fixes it
- ✓StrictMode double-invokes effects in development to expose missing cleanup — fix the effect, do not suppress it
- ✓A correctly cleaned-up effect is already safe to run twice, which is the real test
useEffect(() => {
const id = setInterval(tick, 1000)
return () => clearInterval(id) // write this immediately
}, [])
// Order when a dependency changes from A to B:
// cleanup(A) -> setup(B)
// So the effect is always torn down before it is set up again.
// Anything you START, you must STOP:
setInterval / setTimeout -> clearInterval / clearTimeout
addEventListener -> removeEventListener
new EventSource / WebSocket -> .close()
observer.observe -> observer.disconnect()
a library's init() -> its destroy()
// Without cleanup: the handler still holds the old props in its
// closure, keeps the component in memory, and fires setState on
// something unmounted — a leak, plus stale behaviour.You Probably Do Not Need That Effect
Four patterns account for most unnecessary effects. Removing them makes code shorter, faster and correct in cases it previously got wrong.
- ✓State derived from other state should be computed during render, not synchronised with an effect
- ✓useMemo is for measured expense — filtering dozens of rows does not need it
- ✓Work caused by a user action belongs in the event handler; effects are for work caused by being displayed
- ✓Chained effects that each set the next one's state produce several render passes for one update
- ✓Reset a component's state by changing its key rather than clearing it in an effect, which renders a stale frame first
// Unnecessary — an extra render, and one frame of the wrong value
const [problems, setProblems] = useState([])
const [visible, setVisible] = useState([])
useEffect(() => {
setVisible(problems.filter(p => p.difficulty === level))
}, [problems, level])
// Just compute it
const visible = problems.filter(p => p.difficulty === level)
// Same for totals, counts, formatted values, sorted copies.
// If it is expensive AND you have measured it, wrap in useMemo:
const visible = useMemo(
() => problems.filter(p => p.difficulty === level),
[problems, level],
)
// but "expensive" means thousands of items, not dozens.useRef — DOM Access and Values That Do Not Render
A ref is a box whose contents survive re-renders and whose changes never cause one. Two uses: reaching a DOM node, and remembering something the UI does not display.
- ✓A ref persists across renders and changing it never triggers one
- ✓ref.current is null during the first render, so DOM access belongs in effects and handlers
- ✓Use a ref for timer ids, previous values and flags — anything the UI does not display
- ✓If the user should see the change, it must be state, not a ref
- ✓Reading or writing a ref during render breaks purity; a ref is an escape hatch, not a state alternative
function SearchBox() {
const inputRef = useRef(null)
useEffect(() => {
inputRef.current?.focus() // available after mount
}, [])
return <input ref={inputRef} />
}
// current is null during the first render — the DOM does not exist
// yet — so always use optional chaining.
// Real uses: focus management, scrolling into view, measuring,
// play/pause on media, and passing a node to a library.
node.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
const { height } = node.getBoundingClientRect()
// Forwarding a ref to your own component
const Input = forwardRef(function Input(props, ref) {
return <input ref={ref} {...props} />
})
// In React 19, ref is a normal prop and forwardRef is no longer needed.useReducer — State With Rules
When several pieces of state change together according to rules, a reducer puts those rules in one testable function instead of scattering them across handlers.
- ✓A reducer collects every state transition into one pure, testable function
- ✓Actions describe what happened; the reducer decides what the next state is
- ✓The reducer must stay pure — asynchronous work belongs in the handler or effect around it
- ✓dispatch is stable across renders, so it never needs to be a dependency
- ✓Reducer-plus-context suits a bounded feature; split state and dispatch so dispatch-only consumers do not re-render
const initial = { status: 'idle', code: '', results: null, error: null }
function reducer(state, action) {
switch (action.type) {
case 'edit': return { ...state, code: action.code }
case 'run': return { ...state, status: 'running', error: null }
case 'passed': return { ...state, status: 'passed', results: action.results }
case 'failed': return { ...state, status: 'failed', error: action.error }
case 'reset': return initial
default: throw new Error(`Unknown action: ${action.type}`)
}
}
function Solver() {
const [state, dispatch] = useReducer(reducer, initial)
async function run() {
dispatch({ type: 'run' })
try {
dispatch({ type: 'passed', results: await execute(state.code) })
} catch (e) {
dispatch({ type: 'failed', error: e.message })
}
}
}
// The reducer must be pure: no fetching, no timers, no mutation.
// Async work happens around it, in handlers or effects.StrictMode, Purity and Concurrent Rendering
React assumes your components are pure and may render them more than once, or abandon a render entirely. StrictMode makes violations visible in development.
- ✓React assumes rendering is pure and may call a component twice or discard a render entirely
- ✓Mutating props, writing to the DOM, or using Math.random during render all break that contract
- ✓StrictMode double-invokes components and effects in development to expose impurity and missing cleanup
- ✓Suppressing the double-invoke with a ref hides a bug that returns on any real remount
- ✓useTransition and useDeferredValue keep the UI responsive by marking updates as interruptible
// Impure — mutating a prop or module variable during render
let renderCount = 0
function Bad({ items }) {
renderCount++ // side effect during render
items.sort() // mutating a prop
document.title = 'Problems' // DOM write during render
return <List items={items} />
}
// Pure
function Good({ items }) {
const sorted = [...items].sort()
useEffect(() => { document.title = 'Problems' }, [])
return <List items={sorted} />
}
// Impure in a subtler way — the output depends on something that
// can change between two calls with the same props:
function Row() { return <li>{Math.random()}</li> }
function Row2() { return <li>{new Date().toISOString()}</li> }
// Both break double-render checks and server rendering (hydration
// mismatch: the server produced one value, the client another).Stale Closures and Update Timing
A callback captures the state from the render that created it. When that callback outlives the render — a timer, a listener, a subscription — it keeps reading values that are no longer current.
- ✓Every render creates new callbacks closing over that render's props and state
- ✓A callback that outlives its render — in a timer, listener or subscription — keeps stale values
- ✓The functional updater form removes the dependency on the captured value entirely
- ✓The latest-ref pattern keeps a long-lived callback reading current values without re-subscribing
- ✓State read immediately after setting it is still the old snapshot; use the value you computed, or flushSync when the DOM must update first
function Timer() {
const [count, setCount] = useState(0)
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1) // 'count' is 0 in this closure, forever
}, 1000)
return () => clearInterval(id)
}, []) // set up once — captures the first render
return <p>{count}</p> // goes 0 -> 1, then stops
}
// Every tick computes 0 + 1. The interval was created during the
// first render and closed over that render's count.
// Fix 1 — the functional updater. React supplies the current value,
// so the closure does not need it:
setCount(c => c + 1) // correct, and the deps stay empty
// Fix 2 — depend on it honestly, accepting the effect re-runs:
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000)
return () => clearInterval(id)
}, [count]) // tears down and recreates each second