Pure Component
Create components that render the same output for the same inputs without side effects.
In this article
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 or sends an analytics event, and you can’t predict what it’ll do next.
That makes the component hard to work with: your 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.
You can move data fetching and analytics calls into useEffect or an event handler, so they don’t run during render, and pass the current time in as a prop.
Bugs come from mixing rendering, which describes what the UI looks like, with effects, which handle communication with the outside world.
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 the same props produce the 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 the same props always produce the 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 run in effects rather than in 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 you have to put fetching and subscriptions in useEffect or event handlers rather than running them 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 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 and makes testing straightforward. With side effects in useEffect or event handlers, your render function becomes a simple transformation from props to UI.