Why useContext comes up in every React interview
Context is React's built-in solution to one of the oldest frontend problems: making data
available to components deep in the tree without threading it through every layer. If
you've ever seen a codebase where a user prop is passed from App → Layout →
Sidebar → Nav → Avatar when only Avatar actually uses it, you've seen the
problem context solves. Interviewers use context questions to probe your understanding of
React's data flow model, the performance model underneath the surface, and when to reach
for a library instead.
The three-step pattern: create, provide, consume
Context always involves three pieces. First you create a context object with a default
value. Then a Provider component in the tree passes the real value down. Finally,
any descendant calls useContext to read it.
// 1. create once, usually in its own file
export const ThemeContext = createContext('light')
// 2. provide high in the tree
function App() {
const [theme, setTheme] = useState('dark')
return (
<ThemeContext.Provider value={theme}>
<Page />
</ThemeContext.Provider>
)
}
// 3. consume anywhere below
function Button() {
const theme = useContext(ThemeContext)
return <button className={theme}>click</button>
}
useContext replaces the older <ThemeContext.Consumer> render-prop approach with a
simple function call. It's always cleaner, and it composes correctly when a component
needs to read from multiple contexts.
What the default value actually does
The argument to createContext is the default value — but it's used only when there is
no matching Provider anywhere above the consuming component. It does not act as a
fallback when a Provider exists but passes undefined or null.
const Ctx = createContext('default')
// no provider -> consumer sees 'default'
function Isolated() {
return <Child />
}
// provider explicitly passing undefined -> consumer sees undefined
function App() {
return <Ctx.Provider value={undefined}><Child /></Ctx.Provider>
}
The default value is mainly useful for tests and storybook where you want to render a consumer without wrapping it in the full Provider tree. Set it to a realistic value that makes the component render sensibly in isolation.
The performance model: what triggers a re-render
Every component that calls useContext(Ctx) re-renders whenever the value prop on
the nearest <Ctx.Provider> changes, compared by Object.is. This is the most
important performance fact about context and the source of most context bugs in production.
The classic trap: putting an object literal directly in the Provider.
// new object on every render -> every consumer re-renders
<UserContext.Provider value={{ user, setUser }}>
Every render of the Provider creates a fresh {}, so Object.is sees a new value and
all consumers re-render — even if user and setUser haven't changed at all. Fix it
with useMemo:
const value = useMemo(() => ({ user, setUser }), [user])
<UserContext.Provider value={value}>
Now consumers only re-render when user actually changes.
React.memo does not help
A common misconception: wrapping a consumer in React.memo will protect it from
unnecessary context re-renders. It won't. React.memo skips re-renders when props
don't change, but it has no effect on context — a memoized component still re-renders
when a context value it reads changes.
const Avatar = React.memo(function Avatar() {
const { user } = useContext(UserContext) // still re-renders on UserContext change
return <img src={user.avatarUrl} />
})
The correct place to apply the optimization is the Provider (memoize the value), not the consumers.
Splitting context by change frequency
When one context carries both stable data and frequently-changing data, all consumers re-render on any change — even if they only care about the stable part. The fix is to split the context.
// monolithic: all consumers re-render on every notification change
<AppContext.Provider value={{ user, notifCount }}>
// split: UserContext consumers are unaffected by notification updates
<UserContext.Provider value={user}>
<NotifContext.Provider value={notifCount}>
<App />
</NotifContext.Provider>
</UserContext.Provider>
A useful heuristic: separate contexts by who cares about the change, not by what makes semantic sense together.
The Provider + custom hook pattern
Rather than exporting the raw context object and letting consumers import it directly, encapsulate everything in a custom hook. The hook enforces correct usage and gives a helpful error when a component is accidentally rendered outside its Provider.
// AuthContext.tsx
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const value = useMemo(() => ({ user, setUser }), [user])
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be inside AuthProvider')
return ctx
}
Consumers call useAuth() instead of useContext(AuthContext). If they're outside the
Provider, they get a clear error rather than a silent null. This pattern also makes it
easy to swap the underlying implementation later without changing call sites.
How to update context from a consumer
Include the setter alongside the data in the context value. Consumers call the setter directly — it lives in the Provider's state, so calling it updates the state, causes the Provider to re-render, and propagates the new value to all consumers.
const ThemeContext = createContext(null)
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('dark')
const value = useMemo(() => ({ theme, setTheme }), [theme])
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
function Toggle() {
const { theme, setTheme } = useContext(ThemeContext)
return <button onClick={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}>{theme}</button>
}
setTheme has a stable identity (it's a useState setter), so including it in the
memoized value doesn't cause extra renders.
Context vs. state management libraries
Context is not a replacement for Redux, Zustand, or Jotai — it's a different tool:
| Context | Library (Redux/Zustand) | |
|---|---|---|
| Re-render granularity | All consumers of a value | Only subscribers of the slice |
| Selectors | No | Yes |
| DevTools | None | Yes |
| Middleware | No | Yes (thunk, saga) |
| Good for | Auth, theme, locale | High-churn global state |
Use context for data that changes rarely and is needed broadly — user session, theme, language. Use a library when many components subscribe to different slices of rapidly changing state, or when you need time-travel debugging and middleware.
Testing context consumers
Wrap the component under test in the Provider with a controlled test value. The standard pattern is a helper render function.
function renderWithAuth(ui, { user = { name: 'Test' } } = {}) {
return render(<AuthProvider initialUser={user}>{ui}</AuthProvider>)
}
test('shows username in header', () => {
renderWithAuth(<Header />)
expect(screen.getByText('Test')).toBeInTheDocument()
})
Alternatively, mock the hook for unit-style tests — but a real Provider with controlled values is more robust and closer to production.
Common interview questions at a glance
- What triggers a context consumer to re-render? Any change to the Provider's
valueprop, compared byObject.is. New object references trigger it even if contents are unchanged. - Does
React.memoprotect against context re-renders? No — memoize the value at the Provider, not the consumer. - When would you split a context? When different consumers care about different parts that change at different frequencies.
- What is the default value used for? When no Provider exists above the component;
not when a Provider passes
undefined. - Context vs Redux? Context for low-churn global data; Redux/Zustand for high-churn data with selective subscriptions and middleware.