Context provides a way to pass data through the component tree without threading props through every intermediate level — solving what is known as prop drilling.
// Without Context — every layer must pass `theme` down
<App theme="dark">
<Layout theme="dark">
<Sidebar theme="dark">
<Button theme="dark" />
</Sidebar>
</Layout>
</App>
// With Context — Button reads theme directly
const ThemeContext = createContext('light')
function App() {
return (
<ThemeContext.Provider value="dark">
<Layout /> {/* no theme prop needed */}
</ThemeContext.Provider>
)
}
function Button() {
const theme = useContext(ThemeContext) // reads directly
return <button className={theme}>Click</button>
}
Rule of thumb: Use Context for truly global or widely shared data (theme, locale, auth user). Don't reach for it to avoid one or two levels of prop passing.
createContext(defaultValue) creates a Context object. The
default value is used only when a component reads the context
without a matching Provider above it in the tree — it is not the
initial value of the Provider.
// Default value is 'light' — used only outside any Provider
const ThemeContext = createContext('light')
function App() {
return (
<ThemeContext.Provider value="dark">
<Child /> {/* reads 'dark' */}
</ThemeContext.Provider>
)
}
function Orphan() {
// No Provider above → reads the default 'light'
const theme = useContext(ThemeContext)
return <div>{theme}</div>
}
Rule of thumb: Set the default value to something that makes the component usable in isolation (e.g. in tests or Storybook) without wrapping it in a Provider.
Call useContext(MyContext) at the top level of the component. It
returns the current context value provided by the nearest matching
Provider up the tree.
import { createContext, useContext } from 'react'
const UserContext = createContext(null)
function Greeting() {
const user = useContext(UserContext)
if (!user) return <p>Please log in</p>
return <p>Hello, {user.name}</p>
}
function App() {
const [user] = useState({ name: 'Alice' })
return (
<UserContext.Provider value={user}>
<Greeting />
</UserContext.Provider>
)
}
Rule of thumb: useContext is the modern replacement for the legacy
Context.Consumer render-prop API. Use it exclusively in function
components.
Every component that calls useContext(MyContext) re-renders when
the Provider's value prop changes (by reference). Intermediate
components that don't consume the context are not re-rendered.
const CountCtx = createContext(0)
function Parent() {
const [count, setCount] = useState(0)
return (
// Object literal creates a new reference every render — all consumers re-render
<CountCtx.Provider value={{ count, setCount }}>
<Middle />
</CountCtx.Provider>
)
}
function Middle() {
// does NOT use CountCtx → NOT re-rendered when count changes
return <Consumer />
}
function Consumer() {
const { count } = useContext(CountCtx) // re-renders on every count change
return <span>{count}</span>
}
Rule of thumb: Memoize the context value (useMemo) when the
Provider's parent re-renders frequently to avoid creating a new
reference each time.
Every time the Provider's parent re-renders, a new object or array literal creates a new reference. React's shallow equality check sees a new reference → all consumers re-render even if the data hasn't changed.
// ❌ New object every render → all consumers re-render needlessly
<AuthContext.Provider value={{ user, logout }}>
// ✅ Stable reference — only changes when user or logout changes
const authValue = useMemo(() => ({ user, logout }), [user, logout])
<AuthContext.Provider value={authValue}>
If the value is a primitive (string, number) memoisation is unnecessary because primitives are compared by value.
Rule of thumb: Whenever you pass an object or array as a context
value, wrap it in useMemo.
Yes. Call useContext once per context. Compose Providers by nesting
them at the top of the tree — order only matters if a Provider reads
from a sibling context.
function App() {
return (
<ThemeContext.Provider value="dark">
<AuthContext.Provider value={currentUser}>
<LocaleContext.Provider value="en">
<Router />
</LocaleContext.Provider>
</AuthContext.Provider>
</ThemeContext.Provider>
)
}
function Header() {
const theme = useContext(ThemeContext)
const user = useContext(AuthContext)
const locale = useContext(LocaleContext)
// ...
}
If nesting becomes unwieldy, extract a single AppProviders wrapper
component that composes them all.
Rule of thumb: Create one context per concern; don't cram unrelated values into a single context object.
Use Context when data is needed by many components at different nesting levels and passing it via props would require threading it through many intermediate layers that don't need it themselves.
| Signal | Prefer |
|---|---|
| 1–2 levels of passing | Props |
| Truly global (theme, locale, auth) | Context |
| Frequently changes, many subscribers | External store |
// Props are fine — shallow tree, one consumer
<Page><Card title={title} /></Page>
// Context fits — auth needed everywhere, changes rarely
<AuthContext.Provider value={user}>
<Navbar /><Routes /><Footer />
</AuthContext.Provider>
Rule of thumb: If you're passing the same prop through 3+ levels that don't use it, that's the signal to consider Context.
| Aspect | Context | Redux / Zustand |
|---|---|---|
| Built-in | Yes | External dependency |
| Re-render granularity | All consumers of the context | Selector-level (only subscribe to what you need) |
| DevTools | None | Time-travel, action log |
| Async logic | Manual | Built-in middleware (thunk, saga) |
| Boilerplate | Low | Medium (RTK reduces it) |
Context is best for infrequently changing global values (theme, locale, authenticated user). For frequently updating shared state with many subscribers, a store library avoids cascade re-renders.
Rule of thumb: Don't reach for Redux just because you have global
state. Context + useReducer covers a lot of ground; add a store
when you need fine-grained subscriptions or middleware.
A custom hook hides the context import, adds a helpful error message when used outside the Provider, and creates a stable API so consumers don't need to know which context backs it.
// context/ThemeContext.jsx
const ThemeContext = createContext(null)
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('dark')
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be used inside ThemeProvider')
return ctx
}
// Consumer — no context import needed
const { theme, setTheme } = useTheme()
Rule of thumb: Always export a useFoo() hook instead of
exporting the raw Context object.
Provide separate contexts for state and dispatch (or read vs. write). Components that only dispatch won't re-render when state changes, and vice versa.
const CountStateCtx = createContext(null)
const CountDispatchCtx = createContext(null)
function CountProvider({ children }) {
const [count, dispatch] = useReducer(reducer, 0)
return (
<CountDispatchCtx.Provider value={dispatch}>
<CountStateCtx.Provider value={count}>
{children}
</CountStateCtx.Provider>
</CountDispatchCtx.Provider>
)
}
// Only re-renders when count changes
function Counter() { return <span>{useContext(CountStateCtx)}</span> }
// Never re-renders due to count changes (dispatch is stable)
function IncrBtn() { return <button onClick={() => useContext(CountDispatchCtx)({ type: 'inc' })}>+</button> }
Rule of thumb: If your context value has both frequently-changing state and stable callbacks, put them in separate contexts.
Keep useReducer in a Provider component. Pass state via one context
and the stable dispatch via another (or together if updates are
infrequent).
const StoreCtx = createContext(null)
function reducer(state, action) {
switch (action.type) {
case 'SET_USER': return { ...state, user: action.payload }
case 'LOGOUT': return { ...state, user: null }
default: return state
}
}
export function StoreProvider({ children }) {
const [state, dispatch] = useReducer(reducer, { user: null })
const value = useMemo(() => ({ state, dispatch }), [state])
return <StoreCtx.Provider value={value}>{children}</StoreCtx.Provider>
}
export const useStore = () => useContext(StoreCtx)
This pattern gives you Redux-like action dispatch without adding a dependency — ideal for small to medium apps.
Rule of thumb: useReducer + Context is the sweet spot before you
need Redux's devtools or middleware.
Use useMemo for objects/arrays passed as the context value, and
useCallback for functions, so React sees a stable reference and
skips re-rendering consumers.
function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const logout = useCallback(() => {
setUser(null)
api.logout()
}, []) // stable function reference
const value = useMemo(
() => ({ user, logout }),
[user, logout] // recompute only when user changes
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
Rule of thumb: Any context value that is an object or contains functions should be memoised at the Provider level.
Using null (or undefined) as the default forces consumers to handle
the "no Provider" case explicitly and makes it easy to detect misuse.
A non-null default value makes the component work outside a Provider,
which is useful for testing and Storybook.
// null default — consumers must check or use a custom hook with an error
const AuthContext = createContext(null)
// Non-null default — component works in isolation; good for theming
const ThemeContext = createContext({ mode: 'light', toggle: () => {} })
Choose based on intent:
- If the component cannot work without a Provider → default
null, throw in the custom hook. - If the component can work standalone → provide a real fallback.
Rule of thumb: Auth/session contexts default to null; config/theme
contexts default to a sensible fallback object.
As low as possible while still wrapping all consumers. Placing it too high (e.g. at app root) means unrelated subtrees re-render when the context value changes.
// ❌ Too high — the entire app re-renders when modal state changes
function App() {
const [isOpen, setIsOpen] = useState(false)
return (
<ModalContext.Provider value={{ isOpen, setIsOpen }}>
<Header /><Main /><Footer />
</ModalContext.Provider>
)
}
// ✅ Scoped to the subtree that needs it
function CheckoutPage() {
const [isOpen, setIsOpen] = useState(false)
return (
<ModalContext.Provider value={{ isOpen, setIsOpen }}>
<CheckoutForm /><ConfirmModal />
</ModalContext.Provider>
)
}
Rule of thumb: The Provider should be the lowest ancestor that contains all the components that consume the context.
Wrap the component under test in the Provider and supply a controlled test value. With React Testing Library:
import { render, screen } from '@testing-library/react'
import { ThemeContext } from './ThemeContext'
import ThemedButton from './ThemedButton'
function renderWithTheme(ui, theme = 'light') {
return render(
<ThemeContext.Provider value={theme}>
{ui}
</ThemeContext.Provider>
)
}
test('applies dark class in dark mode', () => {
renderWithTheme(<ThemedButton />, 'dark')
expect(screen.getByRole('button')).toHaveClass('dark')
})
Alternatively, if you use the custom-hook pattern, wrap in the real Provider component from your codebase.
Rule of thumb: Never mock context directly — always wrap with the real Provider so tests exercise the full integration.
More State and Data Flow interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.