useContext: Dependency Injection and Performance Pitfalls
Context as Dependency Injection
In software engineering, Dependency Injection is the pattern of supplying a component with its dependencies from the outside rather than having the component create them itself. The consumer declares what it needs; the system provides it. The consumer does not need to know where the dependency comes from or how it was constructed.
React Context is a dependency injection mechanism. It broadcasts a value to any depth of the component tree, eliminating the need to thread props through every intermediate layer — the pattern known as "prop drilling."
// Create typed contexts
const ThemeContext = createContext<'light' | 'dark'>('light');
const UserContext = createContext<User | null>(null);
// Provider: inject values into the subtree
function App() {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const [user] = useState<User | null>(currentUser);
return (
<ThemeContext.Provider value={theme}>
<UserContext.Provider value={user}>
<AppLayout />
</UserContext.Provider>
</ThemeContext.Provider>
);
}
// Consumer anywhere in the tree
function Avatar() {
const theme = useContext(ThemeContext);
const user = useContext(UserContext);
return (
<img
className={`avatar avatar-${theme}`}
src={user?.avatarUrl}
alt={user?.name ?? 'Guest'}
/>
);
}
Context is well-suited for values that are:
- Genuinely global: theme, locale, authentication state
- Consumed by many unrelated components at various depths
- Changed infrequently relative to how often components re-render
The Critical Performance Characteristic: All Consumers Re-render
This is the most important, and most frequently overlooked, behavior of Context: when a Provider's value changes, every component that calls useContext with that context re-renders — even components that only use a small slice of the value, even if the slice they use has not changed.
const AppContext = createContext<{ user: User | null; notifications: Notification[] } | null>(null);
function App() {
const [user, setUser] = useState<User | null>(null);
const [notifications, setNotifications] = useState<Notification[]>([]);
// When a new notification arrives, notifications changes.
// user has not changed. But ALL consumers of AppContext will re-render.
const value = { user, notifications };
return (
<AppContext.Provider value={value}>
<Header /> {/* Only uses user, but re-renders when notifications changes */}
<NotificationBell /> {/* Uses notifications — correct re-render */}
<MainContent /> {/* Uses neither — still re-renders */}
</AppContext.Provider>
);
}
React has no way to know that Header only cares about user — Context delivers the entire value object, and consumers subscribe to the whole thing. There is no built-in selector mechanism.
The Root Cause: Object Literals Create New References
// Bug: new object on every render — every consumer re-renders on every parent render
function App() {
return (
<AppContext.Provider value={{ user, notifications }}>
{children}
</AppContext.Provider>
);
}
Even if user and notifications have not changed, { user, notifications } creates a new object reference on every render. Context compares the previous and current value by reference equality. New reference means "value changed" — all consumers re-render.
// Partial fix: stabilize the value object with useMemo
function App() {
const value = useMemo(() => ({ user, notifications }), [user, notifications]);
return (
<AppContext.Provider value={value}>
{children}
</AppContext.Provider>
);
}
useMemo prevents pointless re-renders caused by parent re-renders that do not change user or notifications. However, it does not solve the fundamental issue: when notifications changes, components that only use user still re-render.
Strategies to Fix Context Performance
Strategy 1: Split Contexts by Update Frequency
The simplest and most effective approach is to separate data that changes at different rates into different contexts:
// Separate contexts for data with different change frequencies
const ThemeContext = createContext<'light' | 'dark'>('light'); // rarely changes
const UserContext = createContext<User | null>(null); // changes on auth events
const NotificationsContext = createContext<Notification[]>([]); // changes frequently
function App() {
const [theme] = useState<'light' | 'dark'>('light');
const [user] = useState<User | null>(null);
const [notifications, setNotifications] = useState<Notification[]>([]);
return (
<ThemeContext.Provider value={theme}>
<UserContext.Provider value={user}>
<NotificationsContext.Provider value={notifications}>
<AppLayout />
</NotificationsContext.Provider>
</UserContext.Provider>
</ThemeContext.Provider>
);
}
// Header subscribes only to UserContext
// Notifications changing does not affect Header at all
function Header() {
const user = useContext(UserContext);
return <nav className="header">{user?.name ?? 'Guest'}</nav>;
}
This is the most impactful single change you can make to Context performance: group data by how often it changes, not by how logically related it is.
Strategy 2: Separate State from Dispatch
A powerful pattern — especially when using useReducer — is providing state and the dispatch function (or setters) in separate contexts. Dispatch functions are stable references that do not change between renders:
type AuthState = { user: User | null; status: 'idle' | 'loading' | 'error' };
type AuthAction =
| { type: 'LOGIN'; user: User }
| { type: 'LOGOUT' }
| { type: 'LOAD' };
const AuthStateContext = createContext<AuthState>({ user: null, status: 'idle' });
const AuthDispatchContext = createContext<React.Dispatch<AuthAction>>(() => {});
function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(authReducer, { user: null, status: 'idle' });
return (
<AuthDispatchContext.Provider value={dispatch}>
<AuthStateContext.Provider value={state}>
{children}
</AuthStateContext.Provider>
</AuthDispatchContext.Provider>
);
}
// This component only subscribes to dispatch — stable reference, never re-renders from auth state changes
function LogoutButton() {
const dispatch = useContext(AuthDispatchContext);
return <button onClick={() => dispatch({ type: 'LOGOUT' })}>Logout</button>;
}
// This component subscribes to state — re-renders only when auth state changes
function UserBadge() {
const { user, status } = useContext(AuthStateContext);
if (status === 'loading') return <Spinner />;
return <span>{user?.name ?? 'Guest'}</span>;
}
Because dispatch from useReducer is guaranteed to be the same reference across all renders, LogoutButton will only ever re-render if its own internal state changes or if the dispatch context provider re-renders (which it won't, since its value is stable).
Strategy 3: Context Selector Pattern
When you need a large unified context but only want to re-render when a specific field changes, the context selector pattern provides surgical subscriptions. React does not support this natively, but third-party libraries do:
import { createContext, useContextSelector } from 'use-context-selector';
type AppState = {
user: User | null;
notifications: Notification[];
theme: 'light' | 'dark';
};
const AppContext = createContext<AppState>({
user: null,
notifications: [],
theme: 'light',
});
// Only re-renders when user changes — not when notifications or theme changes
function Header() {
const user = useContextSelector(AppContext, state => state.user);
return <nav>{user?.name}</nav>;
}
// Only re-renders when notifications.length changes
function NotificationCount() {
const count = useContextSelector(AppContext, state => state.notifications.length);
return <span>{count}</span>;
}
Manual alternative using React.memo:
function Header() {
// Header re-renders when AppContext changes (no way around this)
const { user } = useContext(AppContext);
// But delegate rendering to a memoized child
return <HeaderContent user={user} />;
}
// HeaderContent only re-renders when user changes
const HeaderContent = React.memo(function HeaderContent({ user }: { user: User | null }) {
return <nav>{user?.name}</nav>;
});
The limitation: Header itself still re-renders on every context change; only HeaderContent's render is skipped. If Header's render function is lightweight (it is here — just extracting a value and calling a child), this is an acceptable trade-off.
React 19: The use() Hook for Context
React 19 introduces use(), which can consume Context — and uniquely, it can do so inside conditional expressions, which useContext cannot:
import { use } from 'react';
function Greeting({ isAuthenticated }: { isAuthenticated: boolean }) {
// useContext cannot be called conditionally — use() can
if (isAuthenticated) {
const user = use(UserContext);
return <h1>Welcome back, {user.name}</h1>;
}
return <h1>Please sign in</h1>;
}
This does not mean you should use conditional context consumption freely — it still requires careful design. But use() allows patterns that useContext structurally forbids, and it unifies the API for consuming both Contexts and Promises under a single hook.
Comparison table:
| Feature | useContext |
use() |
|---|---|---|
| Works in conditional blocks | No | Yes |
| Consumes Promises | No | Yes |
| Triggers Suspense | No | Yes (for Promises) |
| Re-render behavior | Same | Same (for Context) |
| Available in | React 16.3+ | React 19+ |
For most everyday Context consumption, useContext and use() are interchangeable. Prefer use() for new code targeting React 19 and above.
Context vs Alternatives: Choosing the Right Tool
Context vs Prop Drilling
Prop drilling — threading props through intermediate components — has a bad reputation, but it has a genuinely important advantage: explicit data flow. You can trace exactly where data comes from by following the prop chain. There are no invisible dependencies.
Prop drilling is the correct choice when:
- Data travels through 2-3 layers maximum
- Only a small number of specific components need the value
- Components need to be highly reusable (Context couples components to a specific tree shape)
- The data flow is load-bearing for understanding the application
// Two-layer prop drilling — no need for Context
function App() {
const user = useCurrentUser();
return <Layout currentUser={user} />;
}
function Layout({ currentUser }: { currentUser: User }) {
return <Header currentUser={currentUser} />;
}
function Header({ currentUser }: { currentUser: User }) {
return <span>{currentUser.name}</span>;
}
This is fine. Adding Context here would add complexity without benefit.
Context vs External State Management
The fundamental difference between Context and libraries like Zustand, Jotai, or Redux Toolkit is the subscription model:
- Context: all consumers re-render when the provider's value changes
- Zustand / Jotai: components subscribe to specific slices; they re-render only when their subscribed slice changes
// Zustand: precise subscriptions
const useStore = create<AppState>()(set => ({
user: null,
notifications: [] as Notification[],
setUser: (user: User | null) => set({ user }),
addNotification: (n: Notification) =>
set(state => ({ notifications: [...state.notifications, n] })),
}));
// Header subscribes only to user — notifications changes do not cause re-renders
function Header() {
const user = useStore(state => state.user);
return <nav>{user?.name}</nav>;
}
// NotificationBell subscribes only to notification count
function NotificationBell() {
const count = useStore(state => state.notifications.length);
return <span className="badge">{count}</span>;
}
Decision guide:
| Scenario | Best tool |
|---|---|
| Theme, locale, auth user | Context |
| Frequently updating shared state | Zustand, Jotai |
| Server state (API data, caching) | React Query, SWR |
| Complex client state with many actors | Redux Toolkit, Zustand |
| Heavily nested, rarely changing config | Context |
When NOT to Use Context
Context is frequently misused as a "global variable system for React." These use cases are wrong:
Wrong use 1: Server state
Data from API calls, caching, synchronization with backend — these belong in data-fetching libraries, not Context:
// Wrong: Context for server state — no caching, no deduplication, no retry
function UserDataProvider({ children }) {
const [userData, setUserData] = useState(null);
useEffect(() => {
fetch('/api/me').then(r => r.json()).then(setUserData);
}, []);
return <UserDataContext.Provider value={userData}>{children}</UserDataContext.Provider>;
}
// Right: React Query handles caching, deduplication, background refresh
function useCurrentUser() {
return useQuery({
queryKey: ['currentUser'],
queryFn: () => fetch('/api/me').then(r => r.json()),
staleTime: 5 * 60 * 1000, // treat as fresh for 5 minutes
});
}
Wrong use 2: Form state
Form field values are inherently local to the form. Even for complex forms, use React Hook Form or Formik rather than threading form state through Context.
Wrong use 3: High-frequency updates
Mouse position, scroll position, animation frames, WebSocket message streams — anything updating more than a few times per second is wrong for Context. Every update re-renders all consumers. Use refs with imperative updates, or external stores with precise subscriptions.
A Production-Grade Context Architecture
Bringing together the best practices from this chapter:
type User = { id: string; name: string; email: string; role: 'admin' | 'user' };
// Separate state (changes on auth events) from actions (always stable)
const AuthStateContext = createContext<{
user: User | null;
isLoading: boolean;
}>({ user: null, isLoading: true });
const AuthActionsContext = createContext<{
login(email: string, password: string): Promise<void>;
logout(): void;
}>({
login: async () => {},
logout: () => {},
});
function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState({ user: null as User | null, isLoading: true });
useEffect(() => {
validateSession()
.then(user => setState({ user, isLoading: false }))
.catch(() => setState({ user: null, isLoading: false }));
}, []);
// Actions reference setState (stable) — they never need to change
const actions = useMemo(() => ({
async login(email: string, password: string) {
setState(prev => ({ ...prev, isLoading: true }));
try {
const user = await authenticate(email, password);
setState({ user, isLoading: false });
} catch {
setState({ user: null, isLoading: false });
throw;
}
},
logout() {
revokeSession();
setState({ user: null, isLoading: false });
},
}), []); // No deps — actions only reference stable setState
return (
<AuthActionsContext.Provider value={actions}>
<AuthStateContext.Provider value={state}>
{children}
</AuthStateContext.Provider>
</AuthActionsContext.Provider>
);
}
// Typed, ergonomic hook wrappers
export function useAuth() {
return useContext(AuthStateContext);
}
export function useAuthActions() {
return useContext(AuthActionsContext);
}
// Only subscribes to actions — never re-renders due to user state changes
function LoginForm() {
const { login } = useAuthActions();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
return (
<form onSubmit={e => { e.preventDefault(); login(email, password); }}>
<input value={email} onChange={e => setEmail(e.target.value)} type="email" />
<input value={password} onChange={e => setPassword(e.target.value)} type="password" />
<button type="submit">Sign In</button>
</form>
);
}
// Only subscribes to state — re-renders only when auth state changes
function UserAvatar() {
const { user, isLoading } = useAuth();
if (isLoading) return <Skeleton circle />;
if (!user) return null;
return <img src={`/avatars/${user.id}`} alt={user.name} />;
}
Summary
Context is React's built-in dependency injection mechanism — purpose-built for distributing infrequently-changing global values across deep component trees without prop drilling. It excels at theme, locale, auth state, and similar cross-cutting concerns.
Its performance model requires deliberate design: every consumer re-renders when the provider value changes. Mitigating this requires splitting contexts by update frequency, separating state from stable dispatch functions, and selectively applying React.memo to expensive consumers.
For high-frequency shared state, external stores (Zustand, Jotai) with their precise subscription model are a better fit. For server-synchronized state, data-fetching libraries (React Query, SWR) provide capabilities — caching, deduplication, background refresh — that Context cannot.
React 19's use() brings conditional context consumption and API unification, but the fundamental performance characteristics remain the same. Knowing where Context fits and where it does not is a hallmark of mature React architecture.