In one minute
When a provider’s value changes (by Object.is), every component that reads that context re-renders, memoized or not. Three techniques cut that down, each removing a different kind of render:
- Memoize the value (
useMemo), so an unrelated re-render of the provider’s parent doesn’t create a “new” value. - Move the state into a provider component that takes
children, so a change re-renders only the provider and its consumers, not the whole app. - Split the context: state in one, setters in another. Setters never change, so components that only set never re-render.
Don’t do this for every context. It pays off when the value changes often, many components read it, and you’ve measured a problem.
You’ll be able to: predict which components re-render after a context change, and apply each technique where it helps.
1function App() {2 const [count, setCount] = useState(0)3 const [color, setColor] = useState('black')4 return <ThemeContext value={{ color, setColor }}>5 <VisitsButton … /><Main /><ColorPicker /><Footer />6 </ThemeContext>7}89const Footer = memo(function Footer() {10 const { color } = use(ThemeContext) // shows the color11})12const ColorPicker = memo(function ColorPicker() {13 const { setColor } = use(ThemeContext) // only sets it14})- eventVisits click:
{ color, setColor }is a new object on everyApprender, so both memoized consumers render for a click that has nothing to do with the theme. - eventTheme change: everything renders, as expected for a real change.
1function App() {2 const [count, setCount] = useState(0)3 const [color, setColor] = useState('black')4 const value = useMemo(() => ({ color, setColor }), [color])5 return <ThemeContext value={value}>6 <VisitsButton … /><Main /><ColorPicker /><Footer />7 </ThemeContext>8}910const Footer = memo(function Footer() {11 const { color } = use(ThemeContext) // shows the color12})13const ColorPicker = memo(function ColorPicker() {14 const { setColor } = use(ThemeContext) // only sets it15})- eventVisits click: the same value object, so the consumers are left alone.
Mainstill renders: it isn’t memoized and its parent rendered. - eventTheme change: everyone renders,
AppandMainincluded, because the state lives inApp.
1function ThemeProvider({ children }) {2 const [color, setColor] = useState('black')3 const value = useMemo(() => ({ color, setColor }), [color])4 return <ThemeContext value={value}>{children}</ThemeContext>5}6function App() {7 const [count, setCount] = useState(0)8 return <ThemeProvider>9 <VisitsButton … /><Main /><ColorPicker /><Footer />10 </ThemeProvider>11}1213const Footer = memo(function Footer() {14 const { color } = use(ThemeContext) // shows the color15})16const ColorPicker = memo(function ColorPicker() {17 const { setColor } = use(ThemeContext) // only sets it18})- eventVisits click:
Appre-renders its provider element andMain, as before. - eventTheme change: the state change now starts inside
ThemeProvider. Itschildrenare the same elements as before, so only the context readers render.AppandMaindon’t.
1function ThemeProvider({ children }) {2 const [color, setColor] = useState('black')3 return <SetColorContext value={setColor}>4 <ColorContext value={color}>{children}</ColorContext>5 </SetColorContext>6}7const Footer = memo(function Footer() {8 const color = use(ColorContext)9})10const ColorPicker = memo(function ColorPicker() {11 const setColor = use(SetColorContext) // never changes12})- eventVisits click: the same as before.
- eventTheme change:
ColorPickerreads only the setter context, whose value (setColor) never changes, so it doesn’t render either. Only theFooter, which shows the color.
Footer shows the color, a memoized ColorPicker only sets it, and Main uses neither. Each version: one unrelated “Visits” click, then a theme change (all recorded).The example: the store theme
The store has a theme color. A memoized Footer shows it; a memoized ColorPicker only changes it; Main uses neither. App also has an unrelated “Visits” counter.
const Footer = memo(function Footer() {
const { color } = use(ThemeContext)!
log(' render Footer')
return <footer style={{ color }}>Free returns for 30 days</footer>
})
const ColorPicker = memo(function ColorPicker() {
const { setColor } = use(ThemeContext)!
log(' render ColorPicker')
return <button id="color" onClick={() => setColor('purple')}>Purple theme</button>
})
Each version below was recorded with one “Visits” click, then one “Purple theme” click.
1. An inline value
function AppInline() {
const [count, setCount] = useState(0)
const [color, setColor] = useState('black')
log('render App')
return (
<ThemeContext value={{ color, setColor }}>
<button id="count" onClick={() => setCount(count + 1)}>Visits: {count}</button>
<Main />
<ColorPicker />
<Footer />
</ThemeContext>
)
}
Visits click, then theme click:
render App
render Main
render ColorPicker
render Footer
render App
render Main
render ColorPicker
render Footer
The counter has nothing to do with the theme, yet both memoized consumers rendered. { color, setColor } is a new object on every App render, and React compares context values with Object.is. memo can’t help: it only compares props, and context updates go around it.
2. Memoize the value
function AppMemoValue() {
const [count, setCount] = useState(0)
const [color, setColor] = useState('black')
log('render App')
const value = useMemo(() => ({ color, setColor }), [color])
return (
<ThemeContext value={value}>
<button id="count" onClick={() => setCount(count + 1)}>Visits: {count}</button>
<Main />
<ColorPicker />
<Footer />
</ThemeContext>
)
}
render App
render Main
render App
render Main
render ColorPicker
render Footer
The counter no longer touches the consumers. setColor doesn’t need to be in the dependencies: useState setters never change. But a theme change still re-renders App and Main, because the state lives in App.
3. A provider component
function ThemeProvider({ children }: { children: ReactNode }) {
const [color, setColor] = useState('black')
log(' render ThemeProvider')
const value = useMemo(() => ({ color, setColor }), [color])
return <ThemeContext value={value}>{children}</ThemeContext>
}
function AppProvider() {
const [count, setCount] = useState(0)
log('render App')
return (
<ThemeProvider>
<button id="count" onClick={() => setCount(count + 1)}>Visits: {count}</button>
<Main />
<ColorPicker />
<Footer />
</ThemeProvider>
)
}
render App
render ThemeProvider
render Main
render ThemeProvider
render ColorPicker
render Footer
A theme change now starts inside ThemeProvider. Its children were created by App, which didn’t re-render, so they’re the same elements as last time and React skips them (Element Optimization). Only the context readers render. This is the standard shape for real providers: AuthProvider, CartProvider, QueryClientProvider.
4. Split state from setters
const ColorContext = createContext('black')
const SetColorContext = createContext<(c: string) => void>(() => {})
function SplitThemeProvider({ children }: { children: ReactNode }) {
const [color, setColor] = useState('black')
log(' render ThemeProvider')
return (
<SetColorContext value={setColor}>
<ColorContext value={color}>{children}</ColorContext>
</SetColorContext>
)
}
const SplitFooter = memo(function Footer() {
const color = use(ColorContext)
log(' render Footer')
return <footer style={{ color }}>Free returns for 30 days</footer>
})
const SplitColorPicker = memo(function ColorPicker() {
const setColor = use(SetColorContext) // never changes: setters are stable
log(' render ColorPicker')
return <button id="color" onClick={() => setColor('purple')}>Purple theme</button>
})
render App
render ThemeProvider
render Main
render ThemeProvider
render Footer
ColorPicker reads only SetColorContext, whose value is setColor, which never changes. So a theme change re-renders only the footer that displays it.
Summary
| Version | “Visits” click renders | Theme click renders |
|---|---|---|
| Inline value | App, Main, ColorPicker, Footer | App, Main, ColorPicker, Footer |
useMemo value |
App, Main | App, Main, ColorPicker, Footer |
| Provider component | App, ThemeProvider, Main | ThemeProvider, ColorPicker, Footer |
| Split context | App, ThemeProvider, Main | ThemeProvider, Footer |
(From the recordings above. Main renders on a Visits click in every version: its parent rendered and it isn’t memoized.)
How it works
- A provider compares its old and new value with
Object.is. If they differ, React walks down from the provider, finds every component that read that context, and schedules it, skippingmemochecks (React Engine Map,propagateContextChanges). - Each technique removes one kind of render.
useMemoremoves consumer renders when nothing changed. The provider component removes the tree render when something did change. Splitting removes setter-only consumer renders. useReducermakes splitting easy:dispatchis stable, so one context can holdstateand anotherdispatch.
Common mistakes
- Inline object values (
value={{ a, b }}), recorded above. - One giant app context. Every reader of any field re-renders on any change. Split by what changes together.
- Expecting
memoto block context. It doesn’t; a component that shouldn’t re-render must not read the changing context. - Doing all three everywhere. It’s more code to read. For a theme that changes once a session, an inline value is fine.
Interview Q&A
When does a context consumer re-render?
When the nearest provider’s value changes by Object.is, whether or not the consumer is memoized.
Why is <Ctx value={{ state, setState }}> a performance problem?
The object is new on every render of the component that renders the provider, so every consumer re-renders every time, even when nothing changed. Recorded: an unrelated counter click rendered both memoized consumers.
Why does a provider component with children help?
When its state changes, only it re-renders. Its children prop holds elements created by its parent, which didn’t re-render, so React reuses them and only renders the context readers. Recorded: a theme change no longer rendered App or Main.
What does splitting a context buy you?
Components that only call setters read a context whose value never changes, so they never re-render from it. Recorded: after splitting, a theme change rendered only ThemeProvider and Footer.
Should you optimize every context like this?
No. Measure first. These techniques matter for values that change often with many consumers.
Related
- Context with use: the basics, including the memoized cart value.
- Element Optimization: why
childrenfrom above are skipped. - useSyncExternalStore: a store with selectors, for fast-changing shared state.
Sources
- Kent C. Dodds: How to optimize your context value, How to use React context effectively
- react.dev: Optimizing re-renders when passing objects and functions
- Topic order inspired by Kent C. Dodds’ EpicReact React Performance workshop; the example app and code here are this handbook’s own.
Primary source: kentcdodds.com/blog/how-to-optimize-your-context-value