Must knowReact Performance · 02

Optimize Context

Memoize the value, move state into a provider component, split state from setters: who re-renders after each.

On this page · 7 sections
  1. In one minute
  2. The example: the store theme
  3. How it works
  4. Common mistakes
  5. Interview Q&A
  6. Related
  7. Sources

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:

  1. Memoize the value (useMemo), so an unrelated re-render of the provider’s parent doesn’t create a “new” value.
  2. 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.
  3. 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.

Inline value
Theme.tsx
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})
What you see
Visits: 0
Purple theme
Rendered this step
App–
ThemeProvider–
Main–
ColorPicker (memo)–
Footer (memo)–
  1. eventVisits click: { color, setColor } is a new object on every App render, so both memoized consumers render for a click that has nothing to do with the theme.
  2. eventTheme change: everything renders, as expected for a real change.

0 / 0
The store theme in context: a memoized 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

Common mistakes

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.

Sources

Primary source: kentcdodds.com/blog/how-to-optimize-your-context-value