Pure Component
Create components that render the same output for the same inputs without side effects.
In this article
Pure Component
Problem
Your component renders one thing on one render and something different on the next, with the exact same props. Maybe it reads the current time during render, accesses global state, or sends an analytics event, and you can’t predict what it’ll do next.
That makes the component hard to work with: you can’t memoize it, tests pass sometimes and fail others, and the framework can’t skip re-renders because it doesn’t know whether the output will change.
I’ve debugged components that triggered three API calls instead of one because fetching happened during render, and I’ve seen race conditions where stale data overwrote fresh data because a side effect captured an old closure value.
Solution
A pure component works like a pure function: it produces the same output for the same inputs every time. It doesn’t read global state during render, check the current time, use random values, or cause side effects.
Data fetching goes in useEffect rather than happening during render, the current time comes in as a prop, and analytics calls go in an event handler or effect.
Rendering describes what the UI looks like, effects handle communication with the outside world, and mixing the two is where bugs come from.
Once your components are pure, memoization works: wrapped in React.memo, a component skips re-renders when its props haven’t changed. That relies on the same props always producing the same output.
Example
These examples show pure components in each framework, and what goes wrong when a component isn’t pure.
Basic Pure Component
// React.memo skips re-renders when props haven't changed
const Greeting = React.memo(function Greeting({ name, title }) {
return (
<div>
<h1>Hello, {name}!</h1>
{title && <p>{title}</p>}
</div>
);
}); <template>
<div>
<h1>Hello, {{ name }}!</h1>
<p>{{ greeting }}</p>
</div>
</template>
<script>
export default {
props: ['name'],
computed: {
// Cached until name changes, so keep computed properties free of side effects
greeting() {
return `Welcome, ${this.name}!`;
}
}
};
</script> <script>
let { firstName, lastName } = $props();
// Re-runs only when firstName or lastName changes, so keep it free of side effects
let fullName = $derived(`${firstName} ${lastName}`);
</script>
<div>
<h1>{fullName}</h1>
</div> class PureGreeting extends HTMLElement {
static get observedAttributes() { return ['name']; }
attributeChangedCallback(name, oldValue, newValue) {
if (newValue !== oldValue) this.render();
}
render() {
this.innerHTML = `<h1>Hello, ${this.getAttribute('name')}!</h1>`;
}
}
customElements.define('pure-greeting', PureGreeting); Pure vs Impure
The difference between pure and impure comes down to whether output depends solely on inputs:
function ImpureTimestamp({ prefix }) {
const time = new Date().toLocaleTimeString();
return <div>{prefix}: {time}</div>;
} function PureTimestamp({ prefix, time }) {
return <div>{prefix}: {time}</div>;
} Immutable Updates
Memoized components only detect changes through reference comparison, so mutations won’t trigger re-renders:
const UserDisplay = React.memo(({ user }) => <div>{user.name}</div>);
user.name = 'Bob';
<UserDisplay user={user} /> const updated = { ...user, name: 'Bob' };
<UserDisplay user={updated} /> Testing
Pure components are straightforward to test since you can verify that same props produce same output:
test('memoized component skips re-render with same props', () => {
const renderSpy = jest.fn();
const MemoizedGreeting = React.memo(({ name }) => {
renderSpy();
return <div>{name}</div>;
});
const { rerender } = render(<MemoizedGreeting name="Alice" />);
expect(renderSpy).toHaveBeenCalledTimes(1);
rerender(<MemoizedGreeting name="Alice" />);
expect(renderSpy).toHaveBeenCalledTimes(1); // Skipped
rerender(<MemoizedGreeting name="Bob" />);
expect(renderSpy).toHaveBeenCalledTimes(2); // Rendered
});
Side Effects in Effects, Not Render
Fetching during render causes duplicate requests and race conditions, while an effect gives you control over when the fetch runs:
function BadProfile({ userId }) {
const user = fetchUserSync(userId);
return <div>{user.name}</div>;
} function GoodProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => { fetchUser(userId).then(setUser); }, [userId]);
return user ? <div>{user.name}</div> : <div>Loading...</div>;
} Stable Callback References
Inline functions create new references on each render, breaking memoization for children:
function TodoList({ todos }) {
// useCallback keeps the same reference across renders
const handleToggle = useCallback((id) => toggleTodo(id), []);
return todos.map(todo => (
<TodoItem key={todo.id} todo={todo} onToggle={handleToggle} />
));
}
// Only re-renders when todo or onToggle reference changes
const TodoItem = React.memo(({ todo, onToggle }) => (
<li onClick={() => onToggle(todo.id)}>{todo.text}</li>
));
Benefits
- Predictable behavior means same props always produce same output, letting you reason about components in isolation without tracing through global state.
- Memoization works because React.memo can skip re-renders when it knows the props haven’t changed.
- Testing becomes straightforward since you pass props and check output without needing to mock global state or worry about timing.
- Reusability improves because pure components don’t depend on external context that might not exist in a new location, so they work anywhere.
- Timing bugs become less likely when side effects live in effects rather than render, giving you explicit control over when they run.
- Concurrent rendering becomes safe since React can render your component multiple times without triggering duplicate side effects.
Tradeoffs
- Side effects must move out of render, which means fetching, subscriptions, and analytics all go in useEffect or event handlers rather than happening inline.
- Memoization has overhead from prop comparison, and for components with frequently-changing props that comparison work can outweigh the time saved by skipping renders.
- Shallow comparison has limits, so changing
user.namewithout changing theuserreference won’t trigger a re-render even though the data changed. - Callbacks need useCallback because inline functions create new references on every render, which breaks memoization for child components, unless React Compiler is memoizing them for you.
- Imperative integrations need extra plumbing, since focus management, measuring layout, or third-party widgets have to go through refs and effects instead of running in render.
- Profile before optimizing because wrapping everything in React.memo “just in case” adds overhead without benefit if those components weren’t causing problems.
Summary
Pure components produce the same output for the same inputs with no side effects during render, which enables framework optimizations like memoization, makes testing straightforward, and cuts down on timing-related bugs. With side effects in useEffect or event handlers, your render function becomes a simple transformation from props to UI.