The purity contract
Why React's render model requires a component to be a pure function of its props and state, and where the side effects that break that rule belong instead.
Here’s a function. It counts how many times it has rendered, which sounds like a perfectly reasonable thing to want.
let renders = 0;
const Counter = ({ count }: { count: number }) => { renders += 1; return <p>Count: {count} (render #{renders})</p>;};It works on your machine, the number ticks up, you ship it. Then, depending on which corner of React you wander into, it breaks three ways. In development it counts by two. After the React Compiler gets hold of it, it stops counting. And the day a second <Counter> appears on the page, the two corrupt each other’s numbers. Three symptoms, no obvious connection, none reproducing reliably.
They share one cause: that renders += 1 broke a contract you never knew you signed. UI = f(state) and the phase strip Trigger → Render → Reconcile → Commit both relied on something neither stated outright, that running your component is safe to do more than once, at any time, in any order. This lesson names that assumption, turns it into a checklist you run on every component, and tells you where code that can’t satisfy it belongs instead.
A component is a pure function of props and state
Section titled “A component is a pure function of props and state”A component is pure if, given the same props and state, it does two things:
- It returns the same JSX tree.
- It changes nothing outside its own local scope while doing so.
That’s a pure function in the math-class sense, and it’s exactly what UI = f(state) was always claiming. Purity is the fine print of that model: when we wrote f, we meant a pure f.
This is React’s side of a deal. You keep render pure, and in exchange React gets to run, skip, pause, restart, and reorder your renders however it likes to make the app fast, with no effect on the output, because a pure function gives the same answer no matter how many times you call it. The bargain comes down to two rules:
Rule 2 stays abstract until you see what counts as a side effect . Inside render, all of these are off-limits: writing a module-level variable, localStorage.setItem(...), a fetch, document.title = 'New title', setting a ref’s .current, firing an analytics event, reading Math.random() or Date.now(). Each one either leaves a trace the next render will trip over, or makes this render’s output depend on something that wasn’t an input. The fix for each comes later in this lesson; for now, learn to recognize the shared shape.
const PostedAt = ({ createdAt }: { createdAt: number }) => { const minutesAgo = Math.round((Date.now() - createdAt) / 60_000); return <span>{minutesAgo} min ago</span>;};Reads the clock mid-render. Date.now() differs every render, so the same createdAt prop yields a different tree. Render is no longer a function of its inputs alone.
const PostedAt = ({ createdAt, now }: { createdAt: number; now: number }) => { const minutesAgo = Math.round((now - createdAt) / 60_000); return <span>{minutesAgo} min ago</span>;};The clock is now an input. The parent reads Date.now() once and passes it down. Same props in, same tree out, so React can render this as often as it wants without the answer drifting.
The fix wasn’t to compute the time difference differently. It was to move the thing that changes on its own, the clock, out of render and into a prop. Turning an impure dependency into an input is most of what the rest of this lesson teaches.
Why React reserves these rights
Section titled “Why React reserves these rights”Purity would be optional if React called your component exactly once, in a predictable order, every time, like a template engine rendering a page top to bottom. But React reserves four rights a template engine never takes. Each is safe against a pure component and breaks an impure one, and each explains one of the three failures from the start of the lesson.
It can call your component twice per render in development. Strict Mode double-invokes your render function on purpose, in development only, to shake out exactly this bug. A pure component called twice returns the same tree twice, no harm done. The <Counter> runs its renders += 1 twice and counts by two. Strict Mode didn’t cause that bug; it revealed one that was always there, before production could hide it.
It can pause a render partway through and start it over. Under concurrent rendering , React may begin a tree, abandon it when a higher-priority update arrives, and render again from scratch. The abandoned attempt never reaches the screen. A pure component doesn’t care: the discarded render touched nothing, so throwing it away costs nothing. But a side effect that fired during that attempt already happened. You sent the analytics event, or wrote to localStorage, for a render the user never saw, and you’re about to do it again on the retry.
It can skip rendering a component entirely. When a component’s inputs haven’t changed, the React Compiler may reuse last render’s tree and never call your function. For a pure component that’s a free win, since the same inputs produce the same tree anyway. But the <Counter> that bumped renders on every call depended on being called. Skip the call and the side effect doing the real work just stops: the number freezes, and nothing errors.
It can render components in an order you didn’t choose, or not render one at all. Scheduling is React’s job, not yours. Any code that assumes “this renders before that” or “this is guaranteed to render” is a bug waiting for the scheduler to disagree.
The third symptom, two <Counter>s corrupting each other, is the same problem again. A module-level let renders is one variable shared by every instance, so mounting twice makes both copies increment the same counter and each number reflects the other’s renders. State that should belong to one instance leaked into a scope shared by all. Every failure is the same story: you broke the contract, and React used a right it always had.
The compiler only memoizes pure components
Section titled “The compiler only memoizes pure components”For most of React’s history, impurity only bit you if you opted into the features that exercise it, Strict Mode or concurrent rendering, and plenty of teams used neither. That era is over. The React Compiler now reads every component and auto-memoizes the ones it can prove are pure. This is the payoff behind the advice to stop reaching for useMemo and useCallback by hand: write natural code, keep it pure, and the optimizing is free.
Break purity and the compiler neither errors nor warns. It silently skips that component, leaving it un-memoized while the rest of your code gets optimized. The component still works, which is what makes the loss easy to miss. The one signal is in React DevTools, which badges each compiler-optimized component; a missing badge where you expected one usually means a purity violation made the compiler skip it. The badge and the compiler’s configuration belong to a later chapter.
Common purity violations and their fixes
Section titled “Common purity violations and their fixes”Here are the impurities that show up most in real code, each paired with the minimal correct shape, so you can fix a violation on sight rather than just flag it.
Impurity rarely arrives alone. A component starts clean, then someone adds a “new” badge, counts renders for debugging, and tags featured products, and each addition breaks the contract a little more. Here’s a <ProductRow> that has collected three violations. Step through it.
const ProductRow = ({ product, renderCount }: ProductRowProps) => { product.tags.push('featured'); renderCount.current += 1; const isNew = Date.now() - product.createdAt < 60_000;
return ( <tr> <td>{product.name}</td> <td>{isNew ? 'New' : ''}</td> <td>{product.tags.join(', ')}</td> </tr> );};Mutating a prop. product.tags belongs to the parent, so pushing onto it changes the parent’s data from inside a child’s render. It runs every render, so 'featured' piles up: ['sale'], then ['sale', 'featured'], then ['sale', 'featured', 'featured']. Derive a new array instead, or let the data already carry the tag it needs.
const ProductRow = ({ product, renderCount }: ProductRowProps) => { product.tags.push('featured'); renderCount.current += 1; const isNew = Date.now() - product.createdAt < 60_000;
return ( <tr> <td>{product.name}</td> <td>{isNew ? 'New' : ''}</td> <td>{product.tags.join(', ')}</td> </tr> );};Writing to a ref during render. A ref outlives the render: renderCount.current is the same box on every call, so writing to it is a side effect, and reading it back gives a value that depends on how many times render happened to run. Counting renders belongs in DevTools or an effect. Refs are the next chapter’s subject; here, just recognize the pattern.
const ProductRow = ({ product, renderCount }: ProductRowProps) => { product.tags.push('featured'); renderCount.current += 1; const isNew = Date.now() - product.createdAt < 60_000;
return ( <tr> <td>{product.name}</td> <td>{isNew ? 'New' : ''}</td> <td>{product.tags.join(', ')}</td> </tr> );};Reading the clock in render. Date.now() makes isNew depend on when React called this function, not on the props, so two renders a second apart can disagree, which breaks reconciliation. On a server-rendered page it breaks hydration too, since the server’s clock and the browser’s clock won’t match. Compute “is this new” in the parent and pass it down as a prop.
Each is the same mistake in a different form: render touched something that outlives it, or depended on something that wasn’t an input. The cleaned-up version moves every one out.
const ProductRow = ({ product, renderCount }: ProductRowProps) => { product.tags.push('featured'); renderCount.current += 1; const isNew = Date.now() - product.createdAt < 60_000;
return ( <tr> <td>{product.name}</td> <td>{isNew ? 'New' : ''}</td> <td>{product.tags.join(', ')}</td> </tr> );};Three side effects in four lines. It mutates a prop, writes a ref, and reads the clock, all before it returns a single element.
const ProductRow = ({ product, isNew }: ProductRowProps) => ( <tr> <td>{product.name}</td> <td>{isNew ? 'New' : ''}</td> <td>{product.tags.join(', ')}</td> </tr>);Inputs in, tree out. isNew arrives as a prop the parent computed once, the render count is gone, and product.tags is read, never written. Render the same product and isNew a thousand times and you get the same row a thousand times.
That covers three of the six violations you’ll meet most. The other three follow the same logic and need a line each:
Mutating state during render. state.count++ on a value from useState is the same mistake as mutating a prop, because that value is this render’s snapshot, not a mutable field. The fix is the setter, setCount(...), which schedules a new render rather than overwriting the current one.
Reaching for Math.random() to make a key or id. A random key is a fresh identity every render, so React throws away and remounts every row, losing all their state. Use a stable id: generate one when the item is created, or useId for an accessibility id.
localStorage writes, document writes, or network calls in the body. All three are side effects that belong outside render, in an event handler if a user action triggers them, or in useEffect if they synchronize with something external. For now, just recognize that none of them go in the function body.
One pattern is worth memorizing, because you’ll reach for it constantly: when you need a new array derived from a prop, don’t push onto the original. Spread into a fresh one.
props.items.push(newItem);const next = [...props.items, newItem];The first line changes the parent’s array; the second leaves it untouched and hands you a brand-new one to render. The next lesson leans on that spread for state updates, and the whole course leans on it for immutable data. When in doubt, make a new value rather than editing the old one.
Local mutation is fine; the boundary is ownership and lifetime
Section titled “Local mutation is fine; the boundary is ownership and lifetime”You can take “don’t mutate” too far and start cloning every object and avoiding let and .push(). Those aren’t dangerous. Mutating a value you created during this same render is pure, because nothing outside the render can observe it.
This is fine:
const CategoryList = ({ categories }: { categories: string[] }) => { const rows = []; for (const name of categories) { rows.push(<li key={name}>{name}</li>); } return <ul>{rows}</ul>;};rows is created inside this render and gone when the function returns. No other render, no parent, no part of React ever sees it half-built, so pushing to it is pure.
So the rule was never “never mutate.” The sharper version is: never mutate something that outlives this render. The same .push() is pure against a local array and a violation against props.items: same operation, different target, decided by ownership and lifetime, not the verb. Writes that cross the render boundary, to props, state, refs, modules, or the DOM, are the side effects. Writes that stay inside it are how you build the tree.
Sort each of these into the bucket it belongs in.
Each item is something a component might do during render. Sort it by whether it keeps the purity contract or breaks it. Drag each item into the bucket it belongs to, then press Check.
const list = []; list.push(x), then return listprops.user.name = 'Ada'Math.random() read inside the returned JSXDate.now() passed in as a prop and read in renderlocalStorage.setItem('k', v) in the function body.map()Where side effects belong
Section titled “Where side effects belong”The rules leave one question open: if side effects can’t run during render, where do they go? There are exactly two legitimate homes. This is a map of where they live, not a full tour of either; you’ll learn both properly later.
- Event handlers are functions React calls in response to a user action, like a click, a submit, or a keypress, never during render. They’re the home for “do this when the user does that”: saving a form, firing analytics on a click, writing to
localStoragewhen a toggle flips. useEffectis a function React calls after commit, to synchronize with something outside React: a subscription, a timer, a non-React widget.
The habit to plant now, before you’ve seen either API: reach for a handler first, and reach for an effect only to synchronize with something outside React. Most of the work newcomers put into effects belongs in a handler instead, and that instinct saves you from a whole genre of bugs later.
Two of the fixes above called a setter, setCount(...), so here’s just enough to read it. const [value, setValue] = useState(initial) declares one piece of local state. Reading value gives you this render’s snapshot; calling setValue(next) doesn’t change value on the spot, it schedules a re-render in which value reflects next. The full useState story comes in the next chapter.
One last case, because it’s a pure-render violation hiding in an innocent-looking place:
const [tree] = useState(buildHugeTree());const [tree] = useState(() => buildHugeTree());useState(buildHugeTree()) calls buildHugeTree() on every render, even though React keeps only the first result and throws the rest away, so the expensive work runs during render where it has no business being. The fix is small: pass a function, and React calls it only once, on mount. Even here, in a corner you haven’t met yet, the contract holds.
Check your understanding
Section titled “Check your understanding”A module-level counter is incremented inside a component and rendered once inside <StrictMode>.
This runs in development, under Strict Mode. Predict what this program prints, then press Check.
let count = 0;
const Counter = () => { count += 1; console.log(count); return <p>{count}</p>;};
root.render( <StrictMode> <Counter /> </StrictMode>,);Strict Mode deliberately calls render twice in development to surface impurity exactly like this — a side effect (count += 1) inside render. So the body runs twice and logs 1 then 2. In production render runs once, it logs 1, and the off-by-one bug hides until a feature that double-renders exposes it. (In real React DevTools you’d see that second 2 dimmed — the team greys out logs from Strict Mode’s second pass, and can suppress them entirely, precisely because console.log in render is an expected, sanctioned debugging move.)
Now the contract’s edges, where it’s easy to over-correct or under-correct.
Each statement is about the purity contract. Mark it True or False. Mark each statement True or False.
Mutating a local array you created in the same render and returning it breaks the purity contract.
console.log in render violates the purity contract, so you must remove it.
An impure component fails the build under the React Compiler.
Strict Mode’s double-render in development is a bug you should suppress.
Reading Date.now() inside render makes a component impure.