useContext: Every Question I've Been Asked
useContext: Every Question I've Been Asked#
Context is one of those features where the API is simple but the "when should I use it" and "what goes wrong" parts are where people actually get stuck.
So instead of a traditional tutorial, here are the questions that come up most often — from study groups, Slack channels, code reviews — answered as honestly as I can.
"What problem does Context solve?"#
Prop drilling. That is the short answer.
Prop drilling is when you have a piece of data at the top of your component tree and a deeply nested component needs it. You end up passing it through three or four intermediate components that do not use the value themselves. They just relay it downward.
// Without context: AppShell passes currentUser through layers that don't need it
function AppShell({ currentUser }: { currentUser: User }) {
return (
<div className="app">
<TopBar currentUser={currentUser} /> {/* uses it */}
<Sidebar currentUser={currentUser} /> {/* just passes it down */}
<MainContent currentUser={currentUser} /> {/* just passes it down */}
</div>
);
}
function Sidebar({ currentUser }: { currentUser: User }) {
return (
<nav>
<NavLinks /> {/* doesn't need it */}
<UserAccountWidget currentUser={currentUser} /> {/* finally uses it */}
</nav>
);
}
Sidebar does not care about currentUser. It is just a messenger. If currentUser changes shape — say you add a locale field — you have to update the prop interface of every component in the chain, even the ones that never look at the value.
Context eliminates this. You put the value at the top, and any component that needs it reaches for it directly.
"How do I set it up properly?"#
Three pieces: a context with a type, a provider component, and a custom hook. This is the pattern I use everywhere:
import React, { createContext, useContext, useState } from 'react';
interface User {
id: string;
displayName: string;
email: string;
avatarUrl: string | null;
role: 'admin' | 'member' | 'viewer';
}
interface AuthContextValue {
user: User | null;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
// undefined default so we can detect missing provider
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
async function login(email: string, password: string) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message ?? 'Login failed');
}
const data = await response.json();
setUser(data.user);
}
function logout() {
fetch('/api/auth/logout', { method: 'POST' });
setUser(null);
}
return (
<AuthContext.Provider value={{
user,
isAuthenticated: user !== null,
login,
logout,
}}>
{children}
</AuthContext.Provider>
);
}
// Custom hook with a useful error when used outside provider
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
Now any component, no matter how deeply nested, can call useAuth() and get the auth state:
function UserAccountWidget() {
const { user, logout } = useAuth(); // no props needed
if (!user) return null;
return (
<div className="account-widget">
<img src={user.avatarUrl ?? '/default-avatar.png'} alt={user.displayName} />
<span>{user.displayName}</span>
<button onClick={logout}>Sign out</button>
</div>
);
}
"Why the undefined default and the error check?"#
Someone asked this in the study group and it is a good question. If you skip the undefined check and just return whatever useContext gives you, consumers outside the provider get undefined. The error shows up later, somewhere deep in a component, as "Cannot read property 'user' of undefined." That error tells you nothing.
With the check, you get: "useAuth must be used within an AuthProvider." That tells you exactly what is wrong. Five seconds to fix instead of five minutes to diagnose. This is one of those small things that compounds over a project's lifetime.
"When should I use Context vs just passing props?"#
If only two or three closely related components need a value and they are near each other in the tree, just pass props. Props are explicit. You can trace them. Context is implicit — you cannot look at a component's signature and know what contexts it depends on.
Context is the right tool when:
- Many components at different nesting levels need the same value
- The value changes infrequently (auth state, theme, locale, feature flags)
- Prop drilling would require threading through 3+ intermediate components
Context is the wrong tool when:
- A value only matters to a small, closely related group of components
- The value changes frequently (more on this below)
- You are trying to avoid Redux — context is not a state management library
"Does Context replace Redux?"#
No. And I think this is the most common misconception I see.
Context is a dependency injection mechanism. It lets you put a value somewhere high in the tree and read it somewhere lower. It does not give you middleware, devtools, time-travel debugging, or any of the things that make Redux useful for complex state.
That said, for many apps — context + useReducer is genuinely enough. We will cover useReducer next month, and then I will show how combining the two covers a surprising amount of ground.
"What is the performance trap?"#
This is the question that matters most and gets asked least.
When the context value changes, every component that calls useContext with that context re-renders. Every single one. No exceptions.
// Problematic: dashboard metrics update every 5 seconds
// Every consumer of this context re-renders every 5 seconds
const AppContext = createContext<{
user: User | null;
notifications: Notification[];
dashboardMetrics: Metrics; // updated every 5 seconds
theme: Theme;
} | undefined>(undefined);
The ThemeToggle only needs theme. But because it shares a context with dashboardMetrics, it re-renders every 5 seconds for no reason.
The fix: split contexts by how frequently they change.
// Changes rarely (login/logout)
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
// Changes rarely (user preference)
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
// Changes every 5 seconds — only consumers that need live metrics subscribe
const MetricsContext = createContext<MetricsContextValue | undefined>(undefined);
// Changes on new notification
const NotificationsContext = createContext<NotificationsContextValue | undefined>(undefined);
Now a component that only needs auth state does not re-render when metrics update.
"Show me the theme example"#
Since everyone asks:
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextValue {
theme: Theme;
resolvedTheme: 'light' | 'dark';
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
function getSystemTheme(): 'light' | 'dark' {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
const stored = localStorage.getItem('theme') as Theme | null;
return stored ?? 'system';
});
const resolvedTheme: 'light' | 'dark' =
theme === 'system' ? getSystemTheme() : theme;
function setTheme(newTheme: Theme) {
setThemeState(newTheme);
localStorage.setItem('theme', newTheme);
document.documentElement.setAttribute('data-theme', newTheme === 'system' ? getSystemTheme() : newTheme);
}
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
const context = useContext(ThemeContext);
if (!context) throw new Error('useTheme must be used within ThemeProvider');
return context;
}
function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<select
value={theme}
onChange={e => setTheme(e.target.value as Theme)}
aria-label="Select color theme"
>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
);
}
Theme is the textbook context use case because it is needed by many components and changes infrequently. Auth is the other perfect case.
"Can a component use multiple contexts?"#
Yes:
function NotificationBell() {
const { user } = useAuth();
const { notifications, markAllRead } = useNotifications();
if (!user) return null;
const unreadCount = notifications.filter(n => !n.isRead).length;
return (
<button
className="notification-bell"
onClick={markAllRead}
aria-label={`${unreadCount} unread notifications`}
>
<BellIcon />
{unreadCount > 0 && (
<span className="notification-badge">{unreadCount}</span>
)}
</button>
);
}
"Any other traps I should know about?"#
Providing a new object reference on every render. If you do <Context.Provider value={{ user, logout }}> inline, a new object is created every render and all consumers re-render even if user and logout have not actually changed. Memoize the context value or use stable references.
Using context for frequently updating values. A context that holds a value changing 10 times per second will cause 10 re-renders per second in every consumer. For high-frequency updates, consider a subscription pattern like Zustand or a custom event emitter.
Using context for server state. Data from API calls — caching, revalidation, loading states, error handling — is better served by libraries like React Query or SWR. They were designed for this problem and they solve it much better than hand-rolled context.
Context with useContext is one of the features that genuinely improved my code. Before hooks, accessing context required render props or HOCs. useContext makes it as simple as calling a function. The pattern I reach for every time: undefined default, provider with state, custom hook with error check. Simple, predictable, and the error messages actually help when something goes wrong.
Next up: useReducer, which is what you reach for when your useState logic starts looking like a tangled mess.