The purity contract
The rule at the heart of React's render model, why a component must 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. You run it on your machine, the number ticks up, you ship it. Then, depending on which corner of React you wander into, it breaks in three different ways. In development it counts by two instead of one. After the React Compiler gets hold of it, it stops counting entirely. And the day a second <Counter> appears on the page, the two of them start corrupting each other’s numbers. That is three symptoms with no obvious connection, and none of them reproduce reliably.
They all have the same cause. That renders += 1 broke a contract you didn’t know you had signed. The previous lesson assumed that render produces the same tree to diff against. The one before it gave you UI = f(state) and the phase strip Trigger → Render → Reconcile → Commit. Both relied on something neither of them 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 you write, and tells you where the code that can’t satisfy it is supposed to live instead.
A component is a pure function of props and state
Section titled “A component is a pure function of props and state”Here is the contract, stated precisely. 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 isn’t a new topic bolted onto the render model. It’s the fine print of the model you already hold: when we wrote f, we meant a pure f, one that gives the same tree for the same inputs and leaves nothing behind.
Think of it as 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. You never have to think about when it does, 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 full catalog, with the fix for each, comes later in this lesson. For now it’s enough to recognize the shape they share.
The contrast is what makes the rule concrete, so look at the two versions side by side.
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() is a different number every render, so this component returns a different tree from the same createdAt prop. 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 many times 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. Taking an impure dependency and turning it into an input is most of what the second half of this lesson is about.
Why React demands this
Section titled “Why React demands this”You now have the rule. This section explains why it isn’t optional.
Purity would be a nice-to-have if React called your component exactly once, in a predictable order, every time, the way a template engine renders a page top to bottom. But React reserves the right to do four things that a template engine never would. Each one is safe against a pure component and breaks an impure one.
Take them one at a time: each one 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 class of bug. A pure component called twice returns the same tree twice, with no harm done. The <Counter> from the start of the lesson, with its renders += 1, runs that line twice and counts by two. Strict Mode didn’t cause the bug; it revealed one that was always there, just before production could hide it. The mechanism behind Strict Mode is its own topic, which the next chapter’s lesson on effects and Strict Mode covers. Here it only plays the messenger.
It can pause a render partway through and start it over. Under concurrent rendering, React may begin rendering a tree, abandon it because a higher-priority update arrived, and render again from scratch. The abandoned attempt is never committed to 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 the abandoned 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 the tree from last time and simply not call your function. For a pure component that’s a free win, since the same inputs would have produced the same tree anyway. But the <Counter> that bumped renders on every call depended on being called. Skip the call and the side effect that was secretly doing the real work just stops happening. The number freezes, and nothing errors. The compiler is the subject of the next section.
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 from the opening, two <Counter>s corrupting each other, is the same problem one more time. A module-level let renders is one variable shared by every instance of the component. Mount the component twice and both copies increment the same counter, so each one’s number reflects the other’s renders too. State that should belong to one instance leaked into a scope shared by all of them. All three failures come down to the same thing: you broke the contract, and React used a right it always had.
Purity as the 2026 default
Section titled “Purity as the 2026 default”This rule matters more in 2026 than it did a few years ago, and it’s worth understanding why.
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. You could write an impure component and never feel it. That era is over. The React Compiler now reads every component in your codebase and auto-memoizes the ones it can prove are pure. This is the payoff this chapter’s opening lesson promised when it told you to stop reaching for useMemo and useCallback by hand: write natural code, keep it pure, and the compiler does the optimizing for free.
The catch is what happens when you break purity. The compiler doesn’t error, and it doesn’t warn in the build log. It silently skips that component: it leaves it un-memoized, rendering the old way, while the rest of your codebase gets memoized. The component keeps working, and that is what makes the problem easy to miss, because nothing tells you the optimization fell off. The one signal lives in React DevTools, which puts a badge on each compiler-optimized component. A missing badge on a component you expected to be optimized usually means the compiler skipped it over a purity violation, so it’s worth a glance. The badge’s mechanics and the compiler’s configuration belong to the React Compiler chapter, not here. For now the takeaway is simple: the compiler only optimizes components it can prove pure, so an impure component silently misses the optimization the rest of your stack gets.
The violations you’ll actually write, and their fixes
Section titled “The violations you’ll actually write, and their fixes”With the principle in place, here are the specific impurities that show up most often in real code, each paired with the minimal correct shape. Learn the fix, not just the prohibition, because knowing the fix is what lets you correct a violation on sight rather than just flag it.
In real code, impurity rarely shows up alone. It accumulates over time. A component starts clean, then someone needs a “new” badge, then someone wants to count renders for debugging, then someone tags featured products, and each addition breaks the contract a little more. Here’s a <ProductRow> that has collected three violations the way real components do. 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; pushing onto it changes the parent’s data from inside a child’s render. Worse, it runs every render, so 'featured' piles up: ['sale'], then ['sale', 'featured'], then ['sale', 'featured', 'featured']. Never mutate a prop. 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. Writing to it is therefore a side effect, and once React renders more than once, reading it back gives a value that depends on how many times render happened to run. Counting renders belongs in DevTools or an effect, not in the function body. Refs are the next chapter’s subject; here it’s enough to 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 happened to call this function, not on the props. Two renders a second apart can disagree, which breaks reconciliation. On a server-rendered page it breaks hydration the same way, because the server’s clock and the browser’s clock won’t match. The fix is to compute “is this new” in the parent and pass it down as a prop.
Each of those is the same mistake in a different form: render reached out and touched something that outlives it, or depended on something that wasn’t an input. The cleaned-up version moves every one of them out. Compare the two:
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, since that’s a debugging concern, not a rendering one. 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 only a line each:
Mutating state during render. state.count++ on a value you got 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 writing over the current one. We’ll cover the setter in a moment.
Reaching for Math.random() to make a key or id. A random key is a fresh identity every render. Reconciliation from the previous lesson matches elements by key, so a key that changes every time makes React throw away and remount every row on every render, losing all their state. The pure fix is a stable id: generate one once when the item is created, or use useId for an accessibility id, a tool from the next chapter that we name here rather than teach.
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. A later chapter’s effects lesson covers that surface. For now, just recognize that none of them go in the function body.
One more pattern is worth committing to memory, 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 reaches into the parent’s array and changes it; the second leaves the parent’s array 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. Build the habit now: when in doubt, make a new value rather than editing the old one.
Local mutation is fine, and where the boundary actually is
Section titled “Local mutation is fine, and where the boundary actually is”There’s a way to over-learn this lesson, and it’s worth heading off now. Developers who just absorbed “don’t mutate” start cloning every object in sight and treating let and .push() as if they were dangerous. They aren’t. Mutating a variable you created during this same render is perfectly pure, because nothing outside the render can ever 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, lives entirely inside this render, and is gone when the function returns. The push mutates it freely, and that’s pure, because no other render, no parent, no part of React will ever see that array in its half-built state. It’s your scratch paper. You can write on it all you want.
So the rule was never “never mutate.” The sharper version is: never mutate something that outlives this render. The exact same .push() call is pure against a local array and a violation against props.items: same operation, different target, different verdict. What decides it is 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 just how you build the tree.
Try the distinction yourself. 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()If the verdict on each one came fast, you have the contract. The whole lesson comes down to making that one judgment quickly: does this write or read reach outside the current render? If it does, it doesn’t belong here.
Where side effects belong, and a minimal state primer
Section titled “Where side effects belong, and a minimal state primer”The rules leave one question open. If side effects can’t run during render, where do they go? There are exactly two legitimate homes, and you’ll learn both properly in the chapters ahead. For now this is a map of where they live, not a full tour of either one.
- Event handlers are functions React calls in response to a user action, like a click, a submit, or a keypress, not during render. They’re the natural home for “do this thing when the user does that thing.” Saving a form, firing analytics on a click, or writing to
localStoragewhen a toggle flips: all handler work. This chapter’s lesson on synthetic events covers them; forms come later. useEffectis a function React calls after commit, to synchronize with something outside React. It’s the home for “after this render lands on screen, go touch the external world”: a subscription, a timer, a non-React widget. A later chapter’s effects lesson covers the API.
The habit worth planting now, before you’ve even seen either API, is this: 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. That instinct will save you from a whole genre of bugs later in the course.
Two of the fixes above used 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 of it. Calling setValue(next) doesn’t change value on the spot; it schedules a re-render in which value will reflect next. That’s the whole primer for now. The full useState story, including lazy initialization, the functional-updater form, and when to reach for useReducer instead, is the next chapter’s opening, and the very next lesson digs into what “this render’s snapshot” really means.
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 single render, even though React keeps only the very first result and throws the rest away. That’s expensive work running during render that has no business being there, exactly the kind of thing this lesson has trained you to avoid. The fix is small: pass a function, useState(() => buildHugeTree()), and React calls it only once, on mount. We’ll give that its full treatment in the next chapter. For now, let it stand as proof that the contract you learned today reaches into corners you haven’t met yet.
Check your understanding
Section titled “Check your understanding”Two quick checks before the next lesson turns to the snapshot model.
Start with a concrete one. Here’s a tiny program with a module-level counter incremented inside a component, rendered once inside <StrictMode>. Predict what it logs.
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.)
Then a round on the contract’s edges, the places 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.