The Context API is React's built-in mechanism for sharing values across
a component tree without passing props through every intermediate layer. You
create a context with createContext, wrap a subtree in its Provider, and
any descendant can read the value with useContext.
const ThemeContext = createContext('light') // default value
function App() {
return (
<ThemeContext.Provider value="dark">
<Layout /> {/* no prop threading needed */}
</ThemeContext.Provider>
)
}
function Button() {
const theme = useContext(ThemeContext) // reads 'dark' directly
return <button className={theme}>Click</button>
}
Its primary use case is ambient, infrequently-updated global data — the authenticated user, current locale, colour theme, or feature flags. These values are read often but change rarely, so the re-render cost is low.
Rule of thumb: Context is a dependency-injection mechanism, not a state manager. Reach for it when you want to avoid prop drilling, not when you need a sophisticated state architecture.
Context is a transport layer — it teleports a value from a Provider
to any consumer without passing props. It owns no opinion about how state
is structured or updated; you still call useState/useReducer yourself.
Redux is a state architecture — a single, centralised store with strict rules: state is read-only, updates happen through serialisable actions, and reducers are pure functions that produce the next state. Middleware, selectors, and DevTools are first-class concepts.
// Context — you manage the state, Context just shares it
const [user, setUser] = useState(null)
<UserContext.Provider value={{ user, setUser }}>...</UserContext.Provider>
// Redux — the store manages everything; components just dispatch/select
dispatch({ type: 'auth/login', payload: { id: 1, name: 'Ada' } })
const user = useSelector(state => state.auth.user)
Think of it this way: Context answers "how do I share state?" — Redux answers "how do I manage state?"
Rule of thumb: If you find yourself bolting a reducer, middleware, and selector logic onto Context, you've reinvented Redux — just use Redux.
Every component that calls useContext(MyContext) re-renders whenever
the Provider's value reference changes — regardless of whether the
specific slice of that value the component uses actually changed. React
has no built-in selector granularity for Context.
const AppContext = createContext()
function Provider({ children }) {
const [user, setUser] = useState(null)
const [cart, setCart] = useState([])
// New object every render — both user AND cart consumers re-render
// even if only `cart` changed
return (
<AppContext.Provider value={{ user, setUser, cart, setCart }}>
{children}
</AppContext.Provider>
)
}
function UserBadge() {
const { user } = useContext(AppContext) // re-renders on every cart update too
return <span>{user?.name}</span>
}
This becomes a real problem when the context value updates frequently (e.g. every keystroke, every animation frame, a live-updating cart) and many components are subscribed.
Rule of thumb: Context re-render overhead is acceptable for values that change a few times per session; it becomes a bottleneck for values that change multiple times per second or that are consumed by dozens of components.
Context shines for infrequently-updated, app-wide ambient data where you need broad access but not granular subscriptions:
- Auth user — set on login/logout, read in many places, almost never changes during a session.
- Theme / colour mode — toggled once in a while, consumed by many components for styling.
- Locale / i18n — changes only when the user switches language.
- Feature flags — loaded once at startup.
- UI preferences — sidebar collapsed, modal stack.
// A practical, well-scoped auth context
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const login = useCallback(async (creds) => { /* ... */ }, [])
const logout = useCallback(() => setUser(null), [])
// Memoised so reference only changes when user changes
const value = useMemo(() => ({ user, login, logout }), [user, login, logout])
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
Rule of thumb: If the value changes less than once per user action, Context is probably the right tool.
Redux earns its keep when several of these conditions are true:
- High-frequency updates — shopping carts, real-time feeds, form state with many interdependent fields.
- Complex async — sequences of API calls with loading/error/retry states that need to be cancelled, debounced, or coordinated.
- Cross-cutting derived state — selectors that combine slices from multiple parts of the store.
- Large team — enforced conventions (action types, reducer boundaries) prevent accidental coupling.
- Audit / replay requirements — you need a full log of every state change for debugging or analytics.
// Redux Toolkit slice — clear action/reducer contract
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [], status: 'idle' },
reducers: {
addItem: (state, action) => { state.items.push(action.payload) },
removeItem: (state, action) => { state.items = state.items.filter(i => i.id !== action.payload) },
},
})
Rule of thumb: If you start adding "Redux-like" patterns to Context (reducers, action types, middleware), stop and use Redux Toolkit instead.
The key insight is that each useContext call subscribes to one
context. If you split your monolithic context into smaller, focused
contexts, a component that consumes only one will not re-render when
another changes.
// ❌ One big context — UserBadge re-renders on every cart change
<AppContext.Provider value={{ user, cart }}>
// ✅ Separate contexts — UserBadge only subscribes to UserContext
const UserContext = createContext(null)
const CartContext = createContext(null)
function Providers({ children }) {
const [user] = useState(null)
const [cart, setCart] = useState([])
return (
<UserContext.Provider value={user}>
<CartContext.Provider value={{ cart, setCart }}>
{children}
</CartContext.Provider>
</UserContext.Provider>
)
}
function UserBadge() {
const user = useContext(UserContext) // never re-renders for cart updates
return <span>{user?.name}</span>
}
A good heuristic: each context should have a single reason to change. If you find yourself subscribing to the same context from unrelated components, split it.
Rule of thumb: One concern per context; keep Providers lean so their value references are stable.
Combining useReducer with Context gives you Redux's core pattern —
a single dispatch function, a reducer, and a read-only state view — with
no external dependency.
const CounterStateContext = createContext(null)
const CounterDispatchContext = createContext(null) // separate so consumers that only dispatch don't re-render on state changes
function counterReducer(state, action) {
switch (action.type) {
case 'increment': return { count: state.count + 1 }
case 'decrement': return { count: state.count - 1 }
default: throw new Error(`Unknown action: ${action.type}`)
}
}
export function CounterProvider({ children }) {
const [state, dispatch] = useReducer(counterReducer, { count: 0 })
return (
<CounterStateContext.Provider value={state}>
<CounterDispatchContext.Provider value={dispatch}>
{children}
</CounterDispatchContext.Provider>
</CounterStateContext.Provider>
)
}
// Consuming components
const { count } = useContext(CounterStateContext) // reads state
const dispatch = useContext(CounterDispatchContext) // stable reference, never re-renders for state changes
This pattern works well for small-to-medium apps. What it still lacks: middleware, DevTools, and selector memoisation.
Rule of thumb: Context + useReducer is a great choice up to ~5 reducers; beyond that the manual wiring overhead starts to rival Redux Toolkit's setup cost.
Middleware sits between dispatch and the reducer, intercepts every
action, and can transform, delay, cancel, or trigger side effects before
passing the action along. The two dominant middleware are redux-thunk
(async action creators) and redux-saga (complex async flows with
generators).
// redux-thunk: dispatch a function instead of a plain object
export const fetchUser = (id) => async (dispatch, getState) => {
dispatch({ type: 'users/fetchStart' })
try {
const user = await api.getUser(id)
dispatch({ type: 'users/fetchSuccess', payload: user })
} catch (err) {
dispatch({ type: 'users/fetchFailure', payload: err.message })
}
}
// RTK wraps this with createAsyncThunk for zero boilerplate
export const fetchUser = createAsyncThunk('users/fetch', (id) => api.getUser(id))
Context has no equivalent. You can put an async function in a Context value, but you lose the action-log, DevTools integration, cancellation tokens, and the ability to chain/cancel at the middleware layer. Replicating saga-level coordination (race conditions, debounce, takeLatest) in Context requires re-implementing what middleware already provides.
Rule of thumb: The moment you need async sequencing beyond a single
async/await call, Redux middleware (or React Query for data fetching)
pays for itself immediately.
Redux DevTools is a browser extension (and built-in RTK feature) that gives you a full observable record of your application's state history. Key features:
- Action log — every dispatched action listed in order.
- State diff — exactly what changed in the store for each action.
- Time-travel debugging — jump to any past state by clicking an entry in the log; the UI re-renders as if it's at that point in time.
- Action replay — replay a sequence of actions to reproduce a bug.
- Import/export state — share a state snapshot with a team member.
// RTK configures DevTools automatically in development
import { configureStore } from '@reduxjs/toolkit'
const store = configureStore({
reducer: rootReducer,
// devTools: true by default in dev, false in production
})
Context has no equivalent. A console.log in a reducer or a React DevTools component inspection is the closest you can get, but neither gives you time-travel or serialisable action history.
Rule of thumb: If you've ever said "I wish I could rewind to see what caused this bug," that's the DevTools use case — and it's only available with Redux.
Modern Redux Toolkit (RTK) has dramatically closed the boilerplate gap. Creating a slice, wiring a store, and connecting a component now requires fewer files than a well-structured Context setup.
// RTK: one createSlice call replaces action types + action creators + reducer
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: state => { state.value += 1 }, // Immer under the hood — mutate safely
decrement: state => { state.value -= 1 },
},
})
export const { increment, decrement } = counterSlice.actions
export default counterSlice.reducer
// Context equivalent needs: createContext, Provider component,
// useState or useReducer, custom hooks, and manual memoisation —
// often spread across 3–4 files for a non-trivial piece of state.
The honest comparison in 2024: RTK setup is ~20 lines; a well-structured Context + useReducer setup for the same feature is similar or larger. The Redux tax is now mostly in learning the mental model, not in line count.
Rule of thumb: Don't avoid Redux for boilerplate reasons; the RTK API is concise. Avoid it because your state needs are genuinely simple.
Every time a Provider's parent re-renders, its value prop is a new
object literal — which triggers re-renders in all consumers even if the
data is identical. useMemo stabilises the reference so consumers
only re-render when the underlying data actually changes.
function UserProvider({ children }) {
const [user, setUser] = useState(null)
const [token, setToken] = useState(null)
// ❌ New object every render, even if user/token haven't changed
// return <Ctx.Provider value={{ user, setUser, token, setToken }}>
// ✅ Stable reference — consumers re-render only when user or token changes
const value = useMemo(
() => ({ user, setUser, token, setToken }),
[user, token] // setUser/setToken are stable (from useState)
)
return <UserContext.Provider value={value}>{children}</UserContext.Provider>
}
When is it required? Whenever the Provider's parent can re-render for reasons unrelated to the context value — e.g., it lives at the top of the tree where any ancestor state change would cascade down.
Rule of thumb: Always wrap a Context value object in useMemo if
the Provider is not at the very root of the tree; skip it only when
the Provider is a static singleton that never re-renders.
When you call useContext(MyContext), you subscribe to the entire
context value. There is no built-in way to say "re-render only if
state.count changed" — you always get the whole object, and any change
to any part of it triggers a re-render.
// With Redux + useSelector — granular subscription
const count = useSelector(state => state.counter.count)
// Only re-renders when state.counter.count changes
// With Context — no selector, full value
const { count, user, cart, theme } = useContext(AppContext)
// Re-renders whenever ANY of these change
Redux's useSelector uses strict equality (or a custom comparator)
on the selector's return value. If state.counter.count hasn't changed,
the component skips re-rendering even if other parts of the store did.
Libraries like use-context-selector backport this behaviour to Context,
but at that point you're carrying the complexity of Redux without its
ecosystem.
Rule of thumb: If you need subscription granularity finer than "the whole context value", Redux (or Zustand/Jotai) is the right tool.
Zustand and Jotai occupy the pragmatic middle ground: they provide granular subscriptions and minimal boilerplate without Redux's architectural overhead.
Zustand is a single-store solution. You define state and actions together
in a create call; components subscribe to slices with a selector:
import { create } from 'zustand'
const useStore = create(set => ({
count: 0,
increment: () => set(state => ({ count: state.count + 1 })),
}))
function Counter() {
const count = useStore(state => state.count) // granular — re-renders only when count changes
const increment = useStore(state => state.increment) // stable reference
return <button onClick={increment}>{count}</button>
}
Jotai uses an atomic model: each atom is an independent piece of
state. Components subscribe to individual atoms, so updates are naturally
scoped to only the atoms a component reads.
Neither has Redux DevTools baked in (though Zustand has a devtools middleware) or first-class saga/thunk support.
Rule of thumb: Reach for Zustand when Context starts hurting and Redux feels like overkill; reach for Jotai when you want fine-grained atomic state without a centralised store.
Context — wrap the component under test in its Provider and pass a controlled value. No special setup, no mock store.
// Testing a component that reads from Context
test('shows user name', () => {
render(
<UserContext.Provider value={{ user: { name: 'Ada' } }}>
<UserBadge />
</UserContext.Provider>
)
expect(screen.getByText('Ada')).toBeInTheDocument()
})
Redux — use RTK's configureStore with a real (or pre-populated) store,
or use renderWithProviders from @reduxjs/toolkit/react:
test('increments counter', async () => {
const { store } = renderWithProviders(<Counter />)
await userEvent.click(screen.getByRole('button', { name: /increment/i }))
expect(store.getState().counter.value).toBe(1)
})
Redux gives you direct access to store.getState() and store.dispatch()
in tests, making it easy to assert against the store as well as the UI.
Context is simpler to mock but harder to inspect state independently of
the rendered output.
Rule of thumb: Context tests are simpler to write; Redux tests are more powerful for asserting state transitions independently from rendering.
Multiple contexts let you co-locate state near the component subtrees that use it. Each context has an independent update cycle, so they don't interfere with each other. The cost is coordination: sharing data across contexts requires lifting it to a common ancestor, which can recreate prop-drilling at the context level.
// Fine — independent, non-overlapping concerns
<AuthContext.Provider value={auth}>
<ThemeContext.Provider value={theme}>
<NotificationsContext.Provider value={notifications}>
<App />
</NotificationsContext.Provider>
</ThemeContext.Provider>
</AuthContext.Provider>
One Redux store centralises everything. Any slice can read from any other slice in a selector. The downside is that everything is global by default — there's no encapsulation boundary.
RTK's createSlice recovers some modularity (each slice is a self-contained
module), but ultimately they all merge into one global state tree.
Rule of thumb: Multiple contexts for independent concerns; a Redux store when slices need to derive state from each other or share middleware.
A useful mental checklist — before reaching for a global state solution, ask these questions:
Does only ONE component need this state? → useState, keep it local
Do a FEW co-located components need it? → lift state to nearest common ancestor
Do MANY unrelated components need it? → Context (if infrequent updates)
Is it updated FREQUENTLY or needs middleware? → Redux / Zustand
Is it SERVER state (loading, caching)? → React Query / SWR
Over-globalising state is a common mistake: putting form field values,
modal-open flags, and tooltip visibility into Redux adds noise to the
action log and couples unrelated parts of the app. These are ephemeral
UI state — local useState is almost always the right choice.
Rule of thumb: Default to local state; promote to global only when sharing across unrelated subtrees becomes genuinely painful.
In a Server Component world (Next.js App Router), Context Providers must be Client Components — they rely on React's runtime reconciler, which only runs in the browser. Trying to render a Context Provider in a Server Component throws an error.
// app/providers.tsx — must be a Client Component
'use client'
import { ThemeContext } from './ThemeContext'
export function Providers({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState('dark')
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
// app/layout.tsx — Server Component that wraps with Providers
import { Providers } from './providers'
export default function RootLayout({ children }) {
return <html><body><Providers>{children}</Providers></body></html>
}
Additional gotchas:
- Hydration mismatch — if Context initialises from browser-only APIs (localStorage, cookies), the server-rendered HTML will differ from the client hydration pass, causing warnings.
- Per-request isolation — in traditional SSR, a singleton Context module is shared across requests. Each request must create its own Provider instance; never store per-user data in a module-level variable.
Rule of thumb: In Next.js App Router, keep Providers in a single
'use client' file at the root layout and pass server-fetched data in
as props rather than initialising them from async operations inside the
Provider.
A strong interview answer covers three axes: frequency of updates, complexity of async logic, and team/scale requirements.
Context is right when:
✓ State changes infrequently (theme, auth, locale)
✓ No async middleware needed
✓ Small-to-medium app or isolated subtree
✓ You want zero extra dependencies
Redux (RTK) is right when:
✓ State updates frequently or drives complex UI
✓ Async sequences: loading/error states, cancellation, polling
✓ Derived data across multiple slices (selectors)
✓ Large team needs enforced conventions
✓ Time-travel debugging has real value
Middle ground (Zustand/Jotai):
✓ Granular subscriptions without Redux boilerplate
✓ Stores need to be shared but you don't want a full Redux setup
Interviewers want to hear that you understand the trade-offs, not just "Context is simpler." Mention re-render behaviour, selector granularity, and middleware — those are the differentiators that signal real experience.
Rule of thumb: Start with local state, promote to Context for ambient global data, reach for Redux when your state has behaviour (async, derived data, audit trail) that Context can't model cleanly.
More State Management interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.