React Context Performance: Splitting, Memoizing, and Selectors
Solve React Context performance problems with provider splitting, value memoization, selector patterns, and smart composition strategies.
What you'll learn
- ✓Why Context causes unnecessary re-renders
- ✓Splitting state and dispatch into separate contexts
- ✓Memoizing context values properly
- ✓Selector patterns with use-context-selector
- ✓When to move beyond Context
Prerequisites
- •Basic React knowledge
- •Familiarity with Context API and hooks
React Context is the built-in way to share state across a component tree without prop drilling. It works well for low-frequency updates like themes, locales, and authentication. But when Context holds frequently changing state, every consumer re-renders on every update, even if it only uses a small slice of the value. This tutorial covers the patterns that solve that problem.
The Re-render Problem
When a Context value changes, React re-renders every component that calls useContext for that context. It does not check whether the specific piece of data the component uses has changed.
const AppContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [notifications, setNotifications] = useState([]);
return (
<AppContext.Provider value={{ user, theme, notifications, setUser, setTheme, setNotifications }}>
{children}
</AppContext.Provider>
);
}
In this setup, a component that only reads theme will re-render when notifications changes. The larger the context value, the worse this gets.
Pattern 1: Split Your Contexts
The simplest fix is to separate unrelated state into different contexts. Group values by how often they change and which components consume them.
const UserContext = createContext(null);
const ThemeContext = createContext('light');
const NotificationContext = createContext([]);
function AppProviders({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [notifications, setNotifications] = useState([]);
return (
<UserContext.Provider value={{ user, setUser }}>
<ThemeContext.Provider value={{ theme, setTheme }}>
<NotificationContext.Provider value={{ notifications, setNotifications }}>
{children}
</NotificationContext.Provider>
</ThemeContext.Provider>
</UserContext.Provider>
);
}
Now a component reading useContext(ThemeContext) will not re-render when notifications change. This pattern covers the majority of Context performance issues.
Pattern 2: Separate State and Dispatch
A common refinement is to split a context into a state context and a dispatch context. Components that only call actions (like buttons that dispatch) never need to re-render when state changes.
const TodoStateContext = createContext();
const TodoDispatchContext = createContext();
function todoReducer(state, action) {
switch (action.type) {
case 'add':
return [...state, { id: Date.now(), text: action.text, done: false }];
case 'toggle':
return state.map(t =>
t.id === action.id ? { ...t, done: !t.done } : t
);
case 'delete':
return state.filter(t => t.id !== action.id);
default:
return state;
}
}
function TodoProvider({ children }) {
const [todos, dispatch] = useReducer(todoReducer, []);
return (
<TodoStateContext.Provider value={todos}>
<TodoDispatchContext.Provider value={dispatch}>
{children}
</TodoDispatchContext.Provider>
</TodoStateContext.Provider>
);
}
function useTodos() {
return useContext(TodoStateContext);
}
function useTodoDispatch() {
return useContext(TodoDispatchContext);
}
The dispatch function from useReducer has a stable identity, so TodoDispatchContext never triggers re-renders. The AddTodo component below only needs dispatch:
function AddTodo() {
const dispatch = useTodoDispatch();
const [text, setText] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
dispatch({ type: 'add', text });
setText('');
};
return (
<form onSubmit={handleSubmit}>
<input value={text} onChange={e => setText(e.target.value)} />
<button type="submit">Add</button>
</form>
);
}
This component does not re-render when todos are added or toggled.
Pattern 3: Memoize the Context Value
When you pass an object literal as the context value, React creates a new object on every render, causing all consumers to re-render even if the contents are identical. Wrap the value in useMemo.
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
checkAuth().then(u => {
setUser(u);
setLoading(false);
});
}, []);
const value = useMemo(
() => ({ user, loading, setUser }),
[user, loading]
);
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
Without useMemo, every render of AuthProvider creates a new value object, even if user and loading have not changed. With useMemo, consumers only re-render when the actual data changes.
Pattern 4: Component Composition Instead of Context
Sometimes the best optimization is to avoid Context entirely. If a value is only needed a few levels down, pass it as a prop or use component composition.
// Instead of putting `user` in context for one deeply nested component:
function Page({ user }) {
return (
<Layout>
<Sidebar>
<UserAvatar name={user.name} avatar={user.avatar} />
</Sidebar>
<Content />
</Layout>
);
}
// Or pass the entire rendered component down:
function Page({ user }) {
const avatar = <UserAvatar name={user.name} avatar={user.avatar} />;
return <Layout sidebar={avatar} />;
}
This technique is called “lifting content up.” The parent renders the component that needs the data and passes the rendered result down. No Context needed.
Pattern 5: Selector Libraries
For cases where splitting contexts is impractical, selector libraries like use-context-selector let consumers subscribe to specific slices of a context value.
npm install use-context-selector
import { createContext, useContextSelector } from 'use-context-selector';
const AppContext = createContext(null);
function AppProvider({ children }) {
const [state, dispatch] = useReducer(appReducer, initialState);
return (
<AppContext.Provider value={{ state, dispatch }}>
{children}
</AppContext.Provider>
);
}
// Only re-renders when state.theme changes
function ThemeToggle() {
const theme = useContextSelector(AppContext, v => v.state.theme);
const dispatch = useContextSelector(AppContext, v => v.dispatch);
return (
<button onClick={() => dispatch({ type: 'toggleTheme' })}>
Current: {theme}
</button>
);
}
The useContextSelector hook compares the selector result between renders and skips re-rendering if it has not changed.
Choosing the Right Pattern
Start with the simplest approach and escalate only when you measure a performance problem.
- Split contexts for unrelated state groups. This solves most issues.
- Separate state and dispatch when many components only write, never read.
- Memoize the value to avoid false re-renders from new object references.
- Use composition when data is needed in only a few places.
- Use selectors when you have a large, interconnected context that cannot be split.
If none of these patterns are enough, the state has likely outgrown Context. Move to a dedicated state manager like Zustand, Jotai, or Redux Toolkit, which have fine-grained subscription models built in.
Related articles
- React React Performance: Memo, useMemo, and useCallback Guide
Optimize React performance with React.memo, useMemo, and useCallback. Learn when to use each, how to profile, and avoid common pitfalls.
- React React useTransition: Advanced Patterns for Responsive UIs
Go beyond basic useTransition usage with patterns for search, navigation, tab switching, and priority-based rendering in React concurrent mode.
- React React Compiler and Automatic Memoization
Understand how the React Compiler works, what automatic memoization means, and how it eliminates the need for useMemo, useCallback, and React.memo.
- React React Concurrent Features: useTransition, useDeferredValue, and More
Master React's concurrent rendering features including useTransition, useDeferredValue, and strategies for keeping your UI responsive.