Performance Optimization for State Management
1. React.memo and State Change Prevention
| Technique | Syntax | Purpose | When to Use |
|---|---|---|---|
| React.memo | React.memo(Component) |
Memoizes component, prevents re-render if props unchanged | Pure components receiving same props frequently |
| Custom comparison | React.memo(Comp, areEqual) |
Custom equality function for props comparison | Complex props or specific comparison logic needed |
| Prop stability | Stable references with useCallback/useMemo |
Ensures props don't change unnecessarily | When passing callbacks/objects as props to memoized components |
| Children pattern | {children} prop |
Children don't re-render when parent state changes | Wrap expensive components in parent with local state |
Example: React.memo preventing unnecessary re-renders
import { memo, useState } from 'react';
// ❌ Without memo - re-renders on every parent update
const ExpensiveChild = ({ value }) => {
console.log('ExpensiveChild rendered');
return <div>{value}</div>;
};
// ✅ With memo - only re-renders when value prop changes
const MemoizedChild = memo(({ value }) => {
console.log('MemoizedChild rendered');
return <div>{value}</div>;
});
// ✅ Custom comparison for complex props
const CustomMemoChild = memo(
({ user, settings }) => <div>{user.name}: {settings.theme}</div>,
(prevProps, nextProps) => {
// Return true if props are equal (skip re-render)
return prevProps.user.id === nextProps.user.id &&
prevProps.settings.theme === nextProps.settings.theme;
}
);
function Parent() {
const [count, setCount] = useState(0);
const [value] = useState('constant');
return (
<>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<ExpensiveChild value={value} /> {/* Re-renders every time */}
<MemoizedChild value={value} /> {/* Only renders once */}
</>
);
}
React.memo Best Practices:
- Use for expensive components that receive same props frequently
- Don't overuse - has its own overhead for shallow prop comparison
- Ensure all props passed are stable (use useCallback/useMemo)
- Custom comparison should be cheap - avoid deep equality checks
- Children prop bypasses memo - use composition for optimization
2. useCallback Hook for Event Handler Optimization
| Pattern | Syntax | Purpose | Dependencies |
|---|---|---|---|
| useCallback | useCallback(fn, deps) |
Memoizes function reference between renders | Values used inside callback function |
| Event handler | useCallback((e) => {...}, []) |
Stable reference for event handlers | Empty if no external values used |
| With state | useCallback(() => {...}, [state]) |
Callback using current state value | Include state variables in dependency array |
| Functional update | useCallback(() => setState(s => s + 1), []) |
Update state without depending on current value | Empty deps - uses functional setState |
Example: useCallback optimization patterns
import { useState, useCallback, memo } from 'react';
const Button = memo(({ onClick, label }) => {
console.log(`Rendering ${label}`);
return <button onClick={onClick}>{label}</button>;
});
function TodoList() {
const [todos, setTodos] = useState([]);
const [count, setCount] = useState(0);
// ❌ New function every render - breaks Button memo
const handleBadAdd = () => {
setTodos([...todos, { id: Date.now(), text: 'New' }]);
};
// ✅ Stable reference with functional update - empty deps
const handleAdd = useCallback(() => {
setTodos(prev => [...prev, { id: Date.now(), text: 'New' }]);
}, []); // No dependencies needed with functional update
// ✅ With dependencies - recreates when todos changes
const handleClear = useCallback(() => {
if (todos.length > 0) {
setTodos([]);
}
}, [todos]); // Depends on todos.length check
// ✅ Best: Use functional update to avoid dependency
const handleClearBetter = useCallback(() => {
setTodos(prev => prev.length > 0 ? [] : prev);
}, []); // No dependencies - optimal
return (
<div>
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
<Button onClick={handleAdd} label="Add Todo" />
<Button onClick={handleClearBetter} label="Clear" />
</div>
);
}
useCallback Pitfalls: Don't use useCallback everywhere - it has overhead. Only use when: (1)
Passing callback to memoized child component, (2) Callback is dependency of useEffect/useMemo, (3) Callback
creates expensive closure. Premature optimization can make code harder to read.
3. State Splitting and Component Granularity
| Strategy | Implementation | Benefit | Use Case |
|---|---|---|---|
| Split state by concern | Multiple useState calls instead of one object |
Components re-render only when relevant state changes | Independent state variables that update separately |
| Collocate state | Move state to component that uses it | Reduces parent re-renders, limits update scope | State only used by single child component |
| Lift state down | Push state to lowest common ancestor | Prevents sibling components from re-rendering | State shared by subset of children |
| Extract components | Separate stateful logic into smaller components | Isolates re-renders to affected component tree | Large components with multiple state updates |
❌ Bad: Single state object causes unnecessary re-renders
function Form() {
// All in one state
const [formData, setFormData] = useState({
name: '',
email: '',
count: 0
});
// Every keystroke re-renders entire form
// Even the counter that doesn't care about form inputs
return (
<>
<input
value={formData.name}
onChange={e => setFormData({
...formData,
name: e.target.value
})}
/>
<ExpensiveCounter count={formData.count} />
</>
);
}
✅ Good: Split state by concern
function Form() {
// Split independent state
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [count, setCount] = useState(0);
// Typing in name only re-renders
// name input, not counter
return (
<>
<input
value={name}
onChange={e => setName(e.target.value)}
/>
<ExpensiveCounter count={count} />
</>
);
}
Example: Collocating state for performance
// ❌ State in parent - all children re-render
function Parent() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Header /> {/* Re-renders on isOpen change */}
<Sidebar isOpen={isOpen} setIsOpen={setIsOpen} />
<Content /> {/* Re-renders unnecessarily */}
</>
);
}
// ✅ State collocated in component that needs it
function Parent() {
return (
<>
<Header /> {/* Never re-renders */}
<Sidebar /> {/* Manages own state */}
<Content /> {/* Never re-renders */}
</>
);
}
function Sidebar() {
const [isOpen, setIsOpen] = useState(false);
// State isolated to this component
return <div>...</div>;
}
4. useMemo for Expensive State Derivations
| Pattern | Syntax | Purpose | Dependencies |
|---|---|---|---|
| useMemo | useMemo(() => computation, deps) |
Memoizes computed value, recalculates only when deps change | Input values used in computation |
| Expensive calculation | useMemo(() => expensiveFn(data), [data]) |
Avoid re-computing on every render | Source data for calculation |
| Reference equality | useMemo(() => ({...obj}), [obj]) |
Maintain stable object/array reference | Primitive values that comprise object |
| Filtered/sorted data | useMemo(() => items.filter(...), [items]) |
Cache derived collections | Source collection and filter criteria |
Example: useMemo for expensive computations
import { useState, useMemo } from 'react';
function DataTable({ data, sortKey }) {
const [filter, setFilter] = useState('');
// ❌ Recalculates on every render (even unrelated state changes)
const badSortedData = data
.filter(item => item.name.includes(filter))
.sort((a, b) => a[sortKey] - b[sortKey]);
// ✅ Only recalculates when data, filter, or sortKey changes
const sortedData = useMemo(() => {
console.log('Expensive sort calculation');
return data
.filter(item => item.name.includes(filter))
.sort((a, b) => a[sortKey] - b[sortKey]);
}, [data, filter, sortKey]);
// ✅ Stable object reference for props
const tableConfig = useMemo(() => ({
rowHeight: 50,
columnWidth: 100,
virtualization: true
}), []); // Empty deps - config never changes
return <Table data={sortedData} config={tableConfig} />;
}
// Advanced: Memoize complex selector
function useFilteredItems(items, filters) {
return useMemo(() => {
return items.filter(item => {
return Object.entries(filters).every(([key, value]) => {
if (!value) return true; // Skip empty filters
return item[key]?.toString().toLowerCase()
.includes(value.toLowerCase());
});
});
}, [items, filters]); // Recalc when items or filters change
}
When to use useMemo:
- Expensive calculations - Heavy array operations, complex math, parsing
- Reference equality matters - Object/array passed to memoized component or as effect dependency
- Don't optimize prematurely - Profile first, then optimize bottlenecks
- Avoid for cheap operations - useMemo overhead can exceed benefit
5. Context Performance and Re-render Optimization
| Technique | Implementation | Benefit | Trade-off |
|---|---|---|---|
| Split contexts | Separate frequently/infrequently changing data into different contexts | Components subscribe only to relevant data | More context providers to manage |
| Memoize context value | useMemo(() => ({state, actions}), [state]) |
Prevents context consumers re-rendering on every provider render | Must remember to memoize and manage deps |
| Context selectors | Custom hook to select subset of context | Component only re-renders when selected value changes | Requires additional abstraction layer |
| Component composition | Pass children before context provider | Children don't re-render on context changes | Limited to components that don't need context |
❌ Bad: All consumers re-render
const AppContext = createContext();
function Provider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
// New object every render!
const value = {
user, setUser,
theme, setTheme
};
return (
<AppContext.Provider value={value}>
{children}
</AppContext.Provider>
);
}
// All consumers re-render even if
// they only use theme or only use user
✅ Good: Split contexts
const UserContext = createContext();
const ThemeContext = createContext();
function Provider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const userValue = useMemo(
() => ({ user, setUser }),
[user]
);
const themeValue = useMemo(
() => ({ theme, setTheme }),
[theme]
);
return (
<UserContext.Provider value={userValue}>
<ThemeContext.Provider value={themeValue}>
{children}
</ThemeContext.Provider>
</UserContext.Provider>
);
}
Example: Context selector pattern for fine-grained subscriptions
import { createContext, useContext, useRef, useSyncExternalStore } from 'react';
// Create store outside React
function createStore(initialState) {
let state = initialState;
const listeners = new Set();
return {
getState: () => state,
setState: (newState) => {
state = { ...state, ...newState };
listeners.forEach(listener => listener());
},
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
}
};
}
const StoreContext = createContext(null);
// Provider component
function StoreProvider({ children }) {
const storeRef = useRef();
if (!storeRef.current) {
storeRef.current = createStore({ count: 0, user: null, theme: 'light' });
}
return (
<StoreContext.Provider value={storeRef.current}>
{children}
</StoreContext.Provider>
);
}
// Selector hook - only re-renders when selected value changes
function useStoreSelector(selector) {
const store = useContext(StoreContext);
return useSyncExternalStore(
store.subscribe,
() => selector(store.getState()),
() => selector(store.getState())
);
}
// Usage - component only re-renders when count changes, not theme or user
function Counter() {
const count = useStoreSelector(state => state.count);
const store = useContext(StoreContext);
return (
<button onClick={() => store.setState({ count: count + 1 })}>
{count}
</button>
);
}
Context Performance Warning: Every component using
useContext re-renders when
context value changes, even if using memo. Split contexts by update frequency and use selectors for
fine-grained subscriptions. Consider state management libraries (Zustand, Jotai) for complex scenarios.
6. State Update Batching and flushSync Usage
| Concept | Behavior | React Version | Use Case |
|---|---|---|---|
| Automatic batching React 18 | Multiple setState calls batched automatically in all contexts | React 18+ | Default behavior - optimal for most cases |
| Legacy batching | Only batched in React event handlers (not timeouts, promises) | React 17 and earlier | Manual batching needed for async operations |
| flushSync() | Forces synchronous DOM update immediately | All versions | DOM measurements, third-party integrations |
| unstable_batchedUpdates | Manual batching for React 17 | React 17 (deprecated in 18) | Legacy apps - not needed in React 18 |
Example: Automatic batching in React 18
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
console.log('Render'); // How many times does this log?
// React 17: 3 renders (event handler, then 2 more in timeout)
// React 18: 2 renders (batched in both event handler AND timeout)
function handleClick() {
setCount(c => c + 1); // Batched
setFlag(f => !f); // Batched
// Only 1 re-render for above updates
setTimeout(() => {
setCount(c => c + 1); // React 18: Batched ✅
setFlag(f => !f); // React 18: Batched ✅
// React 18: Only 1 re-render
// React 17: 2 re-renders (not batched in timeout)
}, 0);
}
return <button onClick={handleClick}>Click</button>;
}
// React 18 batches in:
// - Event handlers ✅
// - setTimeout/setInterval ✅
// - Promise.then callbacks ✅
// - Native event handlers ✅
// - Any async code ✅
Example: flushSync for synchronous updates
import { useState, useRef } from 'react';
import { flushSync } from 'react-dom';
function ScrollExample() {
const [items, setItems] = useState([1, 2, 3]);
const listRef = useRef(null);
function handleAdd() {
// ❌ Without flushSync - scroll position won't be accurate
setItems([...items, items.length + 1]);
listRef.current.scrollTop = listRef.current.scrollHeight;
// DOM hasn't updated yet, so scrollHeight is old value
// ✅ With flushSync - forces immediate DOM update
flushSync(() => {
setItems([...items, items.length + 1]);
});
// Now DOM is updated, scrollHeight is correct
listRef.current.scrollTop = listRef.current.scrollHeight;
}
return (
<div ref={listRef} style={{ height: 200, overflow: 'auto' }}>
{items.map(i => <div key={i}>Item {i}</div>)}
<button onClick={handleAdd}>Add Item</button>
</div>
);
}
// Common flushSync use cases:
// 1. DOM measurements (offsetHeight, scrollHeight, getBoundingClientRect)
// 2. Focus management (element.focus())
// 3. Third-party library integration (chart libraries, D3)
// 4. Print dialogs (window.print())
// 5. Selection APIs (window.getSelection())
flushSync Performance Warning:
flushSync disables batching and forces synchronous
rendering, which hurts performance. Only use when you need immediate DOM updates for measurements or
third-party integrations. Overuse can cause performance degradation.
React 17 Manual Batching (Legacy):
import { unstable_batchedUpdates } from 'react-dom';
// React 17 only
setTimeout(() => {
unstable_batchedUpdates(() => {
setCount(c => c + 1);
setFlag(f => !f);
// Now batched in React 17
});
}, 0);
Opt-out of Batching (React 18):
import { flushSync } from 'react-dom';
function handleClick() {
flushSync(() => {
setCount(c => c + 1);
}); // Render immediately
flushSync(() => {
setFlag(f => !f);
}); // Render immediately again
// 2 separate renders (not batched)
}
Performance Optimization Summary:
- React.memo - Prevent re-renders for pure components with stable props
- useCallback - Memoize callbacks passed to memoized children or effect deps
- useMemo - Cache expensive calculations and maintain reference equality
- Split state - Separate independent state for granular re-renders
- Collocate state - Keep state close to where it's used
- Split contexts - Separate fast/slow changing data into different contexts
- Context selectors - Subscribe to subset of context for fine-grained updates
- Automatic batching - React 18 batches all updates automatically
- flushSync sparingly - Only for DOM measurements and third-party integrations
- Profile first - Use React DevTools Profiler before optimizing