Pure Components and Why Purity Matters in React
Learn why React treats your components like pure functions and how keeping renders side-effect free makes your app predictable, testable, and less buggy.
I spent a long time thinking React components were just functions that "do things." You call them, they render HTML, maybe fetch some data, update state. That mental model worked fine for small apps. But as the component tree grew, I started seeing bugs where UI would not reflect the current state, or a component would flash the wrong data before correcting itself.
The fix was understanding a constraint React imposes on every component: your component must be a pure function of its props and state.
What Pure Means in React
A pure function is one that, given the same inputs, always returns the same output and does nothing else. In React terms:
- Same props and state in
- Same JSX out
- No mutation of values that existed before the function ran
That last part is the one I kept violating without noticing. Consider this:
function Badge({ user }: { user: { name: string; visits: number } }) {
if (user.visits > 10) {
user.name = `${user.name} (VIP)`;
}
return <span>{user.name}</span>;
}
The component mutates user.name directly. If Badge renders again with the same user object, the name already has (VIP) appended, so the output changes. Worse, the original user object is now permanently modified in the parent and anywhere else it is used. The component is impure, and the bug manifests as a double-appended (VIP) (VIP) or a silent corruption of data upstream.
The fix is straightforward — do not mutate inputs:
function Badge({ user }: { user: { name: string; visits: number } }) {
const label = user.visits > 10 ? `${user.name} (VIP)` : user.name;
return <span>{label}</span>;
}
Now the component produces the same output for the same user every time, and nothing outside the component is affected by its render.
Why React Insists on Purity
React does not know when it will render your component. It might render it once, twice, ten times. It might render it, throw the result away, and re-render from scratch. The concurrent features in React 18 and later (Suspense, transitions, selective hydration) depend on the ability to start a render, pause it, and resume it later. If your component has side effects inside the render body, that pause-and-resume produces incorrect or duplicated behavior.
Concretely, React assumes it can:
- Call your component function multiple times in development to detect impure patterns
- Throw away the first render if a Suspense fallback needs to show
- Re-render the same component with the same props at any time
If your component mutates a global variable, sends a network request, or writes to localStorage during render, those side effects happen every time React calls the function, even if the render is eventually discarded.
Side Effects Belong in Effects
React gives you a dedicated place for side effects: useEffect (and in React 19, useActionState for actions). The rule I follow now is simple — if it touches anything outside the component, it does not belong in the render body.
// Wrong: side effect during render
function Profile({ userId }: { userId: string }) {
fetch(`/api/users/${userId}`).then(setUser); // runs on every render
return <div>{user?.name}</div>;
}
// Right: side effect in useEffect
function Profile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`).then(setUser);
}, [userId]);
return <div>{user?.name}</div>;
}
The same rule applies to console.log calls you leave in for debugging — those are also side effects. A pure component should produce the same JSX whether or not the devtools are open.
What This Means for Testing
Pure components are trivially testable. You pass props, assert on the output. No mocking, no setup, no worrying about what the previous test did.
test("Badge appends VIP for frequent visitors", () => {
const user = { name: "Alice", visits: 12 };
const { container } = render(<Badge user={user} />);
expect(container.textContent).toBe("Alice (VIP)");
});
If the component mutated the user object, the test would need to check whether the mutation happened, or worse, the mutation would leak into other tests running in the same suite. Purity makes the test isolated by default.
Purity as a Discipline
I found that once I started treating component renders as pure functions, my mental model for React clicked. When I see a bug now, I ask myself: is this render producing different output for the same props? Because that is almost always the root cause — an accidental mutation or a side effect that should have been an effect.
Worth reading next: the React Virtual DOM and Reconciliation article explains how React uses this purity assumption to decide when to update the DOM, and Avoiding Unnecessary Re-renders in React covers what happens when you break that contract accidentally.
Newsletter
A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.