Components and Purity
What you'll learn
- Why a component is just a function, and props are just its arguments, read-only ones
- The two rules of purity, and exactly what counts as a side effect
- Why mutating props or module variables during render corrupts React's core assumptions
- Why local mutation, variables born inside the render, is perfectly safe
- Where side effects belong instead: event handlers and, after commit, effects
- The mindset shift: components have calls, not instances
The previous chapters kept saying "components are pure functions of props and state." This chapter makes that concrete, and enforceable. Purity isn't a style preference; it's a load-bearing wall. React's ability to pause, restart, and throw away renders (you'll see why that matters in the Render and Commit chapter, and again in Part 4) depends entirely on your components playing by two simple rules.
Props are arguments: read-only arguments
Strip away the JSX and a component call is an ordinary function call:
function Badge({ label, count }) {
return (
<span className="badge">
{label}: {count}
</span>
);
}
export default function App() {
return <Badge label="Inbox" count={3} />;
}
What happens:
<Badge label="Inbox" count={3} />creates an element, roughly{ type: Badge, props: { label: 'Inbox', count: 3 } }.- When React renders it, it calls
Badge({ label: 'Inbox', count: 3 }). The props object is the first and only argument. Badgereturns a description. Done. Data flowed one way: parent → child.
Now watch what happens if you treat props as writable:
function Badge({ label, count }) {
label = label.toUpperCase(); // don't do this
return <span>{label}: {count}</span>;
}
There are two separate failures here. First, in development builds React freezes the props object, so assigning to it throws. Second, the deeper reason, mutating label changes nothing upstream: the parent's data is untouched, React's copy of the element is untouched, and your edit is invisible to every future render. You've scribbled on a copy of a description, corrupting React's ability to trust that the description means what it says. Props are read-only for the same reason elements are frozen: descriptions must be trustworthy.
The two rules of purity
Jargon: "pure function". A function where (1) the same inputs always produce the same output, and (2) it causes no observable side effects.
add(2, 3)is pure.Date.now()breaks rule 1.console.logtechnically breaks rule 2, but it's the kind of harmless break everyone tolerates.
Applied to components, during render:
- Rule 1, Same inputs, same output. Given the same props and state, return the same description. No
Math.random(), noDate.now(), no reading mutable globals, unless the randomness or time is stored in state, which makes it an input. - Rule 2, Change nothing outside yourself. Render computes; it must not act.
The side-effect checklist
Jargon: "side effect". Any observable interaction with the world outside the function. The test: if this function ran twice, would the world notice?
During render, all of these are forbidden:
| Side effect | Why it breaks React |
|---|---|
| Fetching / network calls | Render may run twice, pause, or be discarded, duplicate or orphaned requests |
Timers (setTimeout, intervals) | Discarded renders leave zombie timers behind |
| Touching the DOM directly | React owns the DOM; your write fights the commit phase or gets overwritten |
| Writing module-level variables | Renders interleave and restart; the variable becomes a junk drawer |
| Mutating props or shared objects | Corrupts descriptions React is still comparing |
| Mutating state during render | Infinite loops, the update triggers the render that triggers the update |
So where do effects live? Two legal addresses: event handlers (they run because the user acted, not because React rendered) and effects (they run after React commits, previewed below, full treatment in Part 3).
Exhibit A: the impure component
import { useState } from 'react';
// module-level variable — lives outside every render
const seenNames = [];
function VisitorLog({ name }) {
seenNames.push(name); // side effect during render!
return <p>Seen {seenNames.length} visitors, latest: {name}</p>;
}
export default function App() {
const [name, setName] = useState('Ada');
return (
<div>
<VisitorLog name={name} />
<button onClick={() => setName(n => (n === 'Ada' ? 'Grace' : 'Ada'))}>
Switch visitor
</button>
</div>
);
}
What happens:
- First render:
seenNamesbecomes['Ada'], and the screen says "Seen 1 visitors". - Click the button: render runs again, push again, "Seen 2 visitors". Click again: 3, then 4, then 5… But you only ever had two visitors.
- In development with StrictMode, React deliberately renders every component twice, an audit for exactly this bug, so the count starts at 2 and climbs by 2.
- With concurrent features (Part 4), React may start a render, pause, and restart it, pushing duplicates for a render the user never sees.
The number on screen is meaningless. The component "works" only when renders happen exactly as often as you hope, which is precisely what React does not promise.
Exhibit B: the fixed component
The fix flips the direction of data flow: instead of render pushing information out to a module variable, the information comes in through props or state:
import { useState } from 'react';
function VisitorLog({ names }) {
// pure: output depends only on the input
return <p>Seen {names.length} visitors, latest: {names[names.length - 1]}</p>;
}
export default function App() {
const [names, setNames] = useState(['Ada']);
return (
<div>
<VisitorLog names={names} />
<button onClick={() =>
setNames(prev => [...prev, prev[prev.length - 1] === 'Ada' ? 'Grace' : 'Ada'])
}>
Switch visitor
</button>
</div>
);
}
What happens:
- The list of names lives in state, React's sanctioned place for data that changes.
- The button's event handler updates state immutably (
[...prev, next]), which schedules a render. The side effect lives in the handler: legal. VisitorLogis now pure: samenamesin → same description out. Double-render it, discard it, restart it, the output is always right.
Local mutation is fine: here's the line
Purity bans external mutation, not all mutation. Variables born inside this render are yours to abuse:
export default function ShoppingList({ items }) {
const rows = []; // created fresh, right here, on every render
for (const item of items) {
if (item.inStock) {
rows.push(<li key={item.id}>{item.name}</li>); // mutating rows: totally fine
}
}
return <ul>{rows}</ul>;
}
What happens: rows is created at the top of this call, filled, returned inside the description, and forgotten. No other render, no other call, no other anything can ever see it. If React renders twice, each render builds its own rows from scratch. The test is one question: did this variable exist before this render started? If no, mutate away. If yes (props, state, module variables, the DOM), hands off.
Where effects go instead
Some work genuinely must happen because the UI showed something: setting the document title, subscribing to a socket, focusing an input. React's answer is an effect, code you register to run after the render is committed to the screen:
import { useEffect, useState } from 'react';
export default function Inbox() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Inbox (${count})`; // runs after the UI is on screen
}, [count]);
return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}
What happens: render computes the description purely; React commits it to the DOM; then the effect runs, free to touch the outside world, because the render it belongs to is final, not discardable. The dependency array [count] tells React when to re-run it. That's all you need for now; Part 3 gives effects their own chapters.
Calls, not instances: the mindset shift
If you come from class components or other frameworks, you may picture a component as a thing that lives: it's born ("mounted"), it exists for a while, it dies ("unmounted"), and its methods run on that living instance.
Function components ask you to drop that picture. There is no instance. There is a sequence of independent calls:
Pseudocode model, not real source:
// What "a component over time" really is:const desc1 = Counter(props); // render 1: fresh variables, fresh closuresconst desc2 = Counter(props); // render 2: brand-new everythingconst desc3 = Counter(props); // render 3: no memory of desc1's locals
Nothing inside the function survives between calls, no local variable, no closure, except what React deliberately stores for you outside the function (state, in Part 3). "Mounted" just means "this component's element appears in the tree now"; "unmounted" means "its element stopped appearing." Your function doesn't experience a lifetime; it experiences invocations. Every chapter after this one gets easier once that clicks.
Diagram
Rendered diagram (PNG hi-res):
Rendered diagram (PNG hi-res):
Common misconceptions
- Misconception: Pure components can't contain logic. Reality: branches, loops, math, and formatting are all pure. Only outside-world interactions are banned.
- Misconception: Mutating props updates the parent. Reality: props are a frozen, one-way copy. Mutations either throw (in dev) or silently corrupt a description, the parent never learns anything.
- Misconception: All mutation during render is forbidden. Reality: mutation of variables created inside this render is fine, they're invisible to the outside world.
- Misconception: The dev-only double render means your code is misbehaving. Reality: it's a deliberate audit. Impure components produce visibly wrong results when rendered twice, which is exactly how you catch them.
- Misconception: A component is a living object with a lifespan. Reality: a function component is a sequence of independent calls; persistence lives in React-held state, not in your function's locals.
- Misconception: Effects are "where you put code that doesn't fit." Reality: effects are specifically for synchronizing with the outside world after commit, not a general dumping ground.
Why it works this way
- Purity makes renders cheap to attempt and free to abandon. Restartable, pausable rendering (Part 4's concurrency) is impossible if renders have side effects.
- Same-input-same-output enables caching. Memoization (Part 8) is just "skip
f(state)when you've already seen this state", valid only for puref. - One-way, read-only props make data flow traceable. Any value in the tree can be followed up to its source; nothing mutates sideways.
- Effects-after-commit guarantees consistency. The outside world only ever hears about renders that actually made it to the screen.
Try it yourself
- In a dev build, add
props.label = 'hacked'(or reassign a destructured prop) inside a component and read the error. Then fix it by computing a local:const display = label.toUpperCase(). - Reproduce Exhibit A in a StrictMode dev app and log
seenNames.lengthduring render. Watch it climb by 2 per actual change. Then apply the Exhibit B fix and confirm the log matches reality. - Convert a
mapone-liner into therows.push(...)loop style, with aconsole.log(rows.length)after the loop. Confirm each render builds a fresh array, nothing accumulates across renders. - Take a component that calls
Date.now()during render. Make it pure: lift the timestamp into state (set via an event or an effect) and pass it down as a prop. Notice the output is now stable for identical inputs.
Recap
- A component is a function; props are its frozen, read-only argument. Data flows one way: parent → child.
- Purity = same inputs → same output, plus no side effects. During render: no network, no timers, no DOM, no module variables, no prop mutation.
- Local mutation is safe: anything created inside this render is invisible to the outside world.
- Side effects belong in event handlers (user-triggered) or effects (post-commit).
- There are no component instances, only independent calls. Persistence lives in React-held state, not in your function.