React PureComponent shallow comparison fails when props or state contain objects and arrays that are mutated in place instead of recreated with new references. PureComponent uses shallow equality checks, comparing primitives by value but objects and arrays by reference only. When complex data structures are mutated without creating new references, PureComponent won't detect changes and components won't re-render. The fix involves creating new object/array instances, avoiding inline function creation, and understanding when to use memo or custom shouldComponentUpdate instead.
React's PureComponent is a performance optimization that automatically implements shouldComponentUpdate with shallow equality comparison. However, developers often encounter situations where changes don't trigger re-renders, leading to stale UI. Shallow comparison compares primitive types (numbers, strings, booleans) by their actual value, but compares objects and arrays by reference only - whether both point to the same memory address. This means: - If you mutate an array and pass it to a PureComponent, the reference stays the same, so React thinks nothing changed - If you mutate an object property and pass it to a PureComponent, the object reference stays the same, so React skips the re-render - Nested object changes are never detected because only the top-level references are compared This is by design - shallow comparison is fast and prevents unnecessary renders. However, it creates a common pitfall: developers accustomed to deep equality checking end up confused when their UI doesn't update.
Shallow comparison works like this:
// Shallow comparison rules
'hello' === 'hello' // true (primitive by value)
5 === 5 // true (primitive by value)
const arr1 = [1, 2, 3];
const arr2 = [1, 2, 3];
arr1 === arr2 // false (different references)
const arr3 = arr1;
arr1 === arr3 // true (same reference)
// This is what PureComponent does:
const obj1 = { name: 'John' };
const obj2 = { name: 'John' };
obj1 === obj2 // false
// Mutating doesn't change reference:
obj1.name = 'Jane';
obj1 === obj1 // true (reference unchanged)PureComponent compares props and state shallowly - it checks if the objects at the top level reference the same memory location. It does NOT look inside objects to compare nested values.
To verify shallow comparison is the issue:
1. Convert the component to a regular Component and verify it works correctly
2. Check if data is being mutated (modified in place) rather than replaced with new instances
Never mutate arrays in place. Instead, create new array instances:
// DON'T do this with PureComponent:
this.state.items.push(newItem);
this.setState({ items: this.state.items });
// DO this instead - create new array:
this.setState(prevState => ({
items: [...prevState.items, newItem]
}));
// Or use concat:
this.setState(prevState => ({
items: prevState.items.concat(newItem)
}));
// Remove item - don't use splice:
// WRONG:
this.state.items.splice(index, 1);
this.setState({ items: this.state.items });
// CORRECT:
this.setState(prevState => ({
items: prevState.items.filter((_, i) => i !== index)
}));
// Update item in array - don't mutate:
// WRONG:
this.state.items[0].name = 'updated';
this.setState({ items: this.state.items });
// CORRECT:
this.setState(prevState => ({
items: prevState.items.map((item, i) =>
i === 0 ? { ...item, name: 'updated' } : item
)
}));
// Reorder array - don't use sort/reverse on original:
// WRONG:
this.state.items.sort();
this.setState({ items: this.state.items });
// CORRECT:
this.setState(prevState => ({
items: [...prevState.items].sort()
}));The key pattern: Create a NEW array instance every time you need to update it.
Never mutate object properties in place. Create new objects:
// DON'T do this:
this.state.user.name = 'Jane';
this.setState({ user: this.state.user });
// DO this - create new object:
this.setState(prevState => ({
user: { ...prevState.user, name: 'Jane' }
}));
// Update multiple properties:
this.setState(prevState => ({
user: {
...prevState.user,
name: 'Jane',
email: '[email protected]'
}
}));
// Update nested objects - spread at each level:
// WRONG:
this.state.user.address.city = 'New York';
this.setState({ user: this.state.user });
// CORRECT:
this.setState(prevState => ({
user: {
...prevState.user,
address: {
...prevState.user.address,
city: 'New York'
}
}
}));
// Using Object.assign also creates new reference:
this.setState(prevState => ({
user: Object.assign({}, prevState.user, { name: 'Jane' })
}));Always remember: Create a NEW object with the updated values rather than modifying the existing one.
Every time you create a new function, it gets a new reference. This causes PureComponent to think props changed even when they didn't:
// WRONG - creates new function every render:
<ChildPureComponent onClick={() => this.handleClick()} />
// CORRECT - use class method:
<ChildPureComponent onClick={this.handleClick} />
// Or bind in constructor:
class Parent extends React.PureComponent {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
handleClick = () => {
// ...
}
}
// With functional component parent and useCallback:
function Parent() {
const handleClick = React.useCallback(() => {
// ...
}, []);
return <ChildPureComponent onClick={handleClick} />;
}
// With functional components, use memo instead of PureComponent:
const ChildPureComponent = React.memo(function Child({ onClick }) {
return <button onClick={onClick}>Click me</button>;
});The rule: If passing a function to a PureComponent child, ensure the function reference stays the same across renders.
Creating objects or arrays inline in JSX creates new references every render:
// WRONG - new object every render:
<ChildPureComponent style={{ color: 'red' }} />
// CORRECT - define outside or in constructor:
const styles = { color: 'red' };
class Parent extends React.PureComponent {
constructor(props) {
super(props);
this.styles = { color: 'red' };
}
render() {
return <ChildPureComponent style={this.styles} />;
}
}
// WRONG - new array every render:
<ChildPureComponent items={[1, 2, 3]} />
// CORRECT:
class Parent extends React.PureComponent {
constructor(props) {
super(props);
this.items = [1, 2, 3];
}
render() {
return <ChildPureComponent items={this.items} />;
}
}
// With functional components, use useMemo:
function Parent() {
const styles = React.useMemo(
() => ({ color: 'red' }),
[]
);
const items = React.useMemo(
() => [1, 2, 3],
[]
);
return <ChildPureComponent style={styles} items={items} />;
}PureComponent might not be the right choice for all scenarios:
// Use PureComponent for class components with simple props/state:
class SimpleList extends React.PureComponent {
render() {
return <div>{this.props.items.join(', ')}</div>;
}
}
// Use React.memo for functional components:
const SimpleList = React.memo(function({ items }) {
return <div>{items.join(', ')}</div>;
});
// Use custom shouldComponentUpdate for complex comparison logic:
class ComplexComponent extends React.Component {
shouldComponentUpdate(nextProps, nextState) {
// Custom deep comparison or other logic
return !deepEqual(this.props, nextProps) ||
!deepEqual(this.state, nextState);
}
render() {
// ...
}
}
// Use useMemo in functional components for expensive computations:
function Component({ items }) {
const sorted = React.useMemo(
() => items.slice().sort(),
[items]
);
return <div>{sorted.join(', ')}</div>;
}
// Use useCallback to memoize callbacks:
function Parent() {
const handleClick = React.useCallback((id) => {
// ...
}, []);
return <ChildPureComponent onItemClick={handleClick} />;
}When to use each:
- PureComponent: Class components with simple props/state that follow immutability patterns
- React.memo: Functional components that don't need state
- useMemo: Expensive computations that depend on specific dependencies
- useCallback: Function references that need to stay stable across renders
- Custom shouldComponentUpdate: Complex comparison logic that shallow check can't handle
Add logging to verify shallow comparison is working correctly:
class DebugPureComponent extends React.PureComponent {
componentDidUpdate(prevProps, prevState) {
console.log('PureComponent re-rendered');
console.log('Props changed?', prevProps !== this.props);
console.log('State changed?', prevState !== this.state);
}
render() {
return <div>{this.props.data}</div>;
}
}
// In parent:
class Parent extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
user: { name: 'John' },
items: [1, 2, 3]
};
}
updateUser = () => {
// This will correctly trigger re-render
this.setState(prevState => ({
user: { ...prevState.user, name: 'Jane' }
}));
}
addItem = () => {
// This will correctly trigger re-render
this.setState(prevState => ({
items: [...prevState.items, 4]
}));
}
render() {
return (
<>
<DebugPureComponent data={this.state.user.name} />
<button onClick={this.updateUser}>Update User</button>
<button onClick={this.addItem}>Add Item</button>
</>
);
}
}Check browser DevTools console to see if the component re-renders at the expected times. If it's not re-rendering when you expect it to, review steps 2-5 to ensure you're following immutability patterns.
Deep vs Shallow Comparison: Shallow comparison is much faster than deep comparison (O(n) vs O(deep recursion)), which is why React uses it by default. Deep comparison would check every nested property, making it slow for large objects. PureComponent trades deep checking for performance.
Functional Components and Hooks: Modern React development often prefers functional components with hooks (useState, useCallback, useMemo) over class-based PureComponent. React.memo with custom comparison is more flexible for functional components.
Redux and External State: If using Redux, selectors should return the same reference when the underlying data hasn't changed. Libraries like reselect use memoization to ensure shallow comparison works correctly with Redux.
Performance Profiling: Use React DevTools Profiler to identify unnecessary re-renders. Sometimes the issue isn't shallow comparison but other performance problems. Over-optimization with PureComponent can sometimes hide real issues.
Immer Library: If working with deeply nested immutable data becomes tedious, consider using Immer, which allows you to write immutable updates with mutable-looking syntax: produce(state, draft => { draft.user.name = 'Jane'; }).
TypeScript and PureComponent: TypeScript's strict mode will help catch some mutation patterns by preventing reassignment to readonly properties if you properly type your state.
Testing: When testing PureComponent, remember that shallow comparison means the same test data object won't trigger updates. Create new instances in tests: setState({ ...state, prop: newValue }) instead of mutating directly.
Common Library Issues: Some state management libraries (like older versions of Redux) don't guarantee immutability. If using such libraries, you may need custom shouldComponentUpdate or switch to libraries that enforce immutability patterns.
React Hook useCallback has a missing dependency: 'variable'. Either include it or remove the dependency array react-hooks/exhaustive-deps
React Hook useCallback has a missing dependency
Cannot use private fields in class components without TS support
Cannot use private fields in class components without TS support
Cannot destructure property 'xxx' of 'undefined'
Cannot destructure property of undefined when accessing props
useNavigate() may be used only in the context of a <Router> component.
useNavigate() may be used only in the context of a Router component
Cannot find module or its corresponding type declarations
How to fix "Cannot find module or type declarations" in Vite