On this page
Context API: Sharing State Without Prop Drilling
The Prop Drilling Problem
Consider an application where user authentication state needs to be available in the Header, Sidebar, and deep inside various page components. Without Context, you would have to pass the user prop through every intermediate component — even components that do not use it directly:
// Prop drilling — every level must pass user down
function App() {
const [user, setUser] = useState<User | null>(null);
return <Layout user={user} onLogout={() => setUser(null)} />;
}
function Layout({ user, onLogout }: LayoutProps) {
return (
<>
<Header user={user} onLogout={onLogout} />
<Main user={user} />
</>
);
}
function Header({ user, onLogout }: HeaderProps) {
return <nav><UserMenu user={user} onLogout={onLogout} /></nav>;
}
// UserMenu is the only component that actually needs user
function UserMenu({ user, onLogout }: UserMenuProps) {
if (!user) return <a href="/login">Login</a>;
return <button type="button" onClick={onLogout}>{user.name}</button>;
}Context solves this by making values available to any descendant component without explicit prop passing.
Creating a Context
The pattern has three parts: create the context, provide it, and consume it.
import { createContext, useContext, useState, type ReactNode } from 'react';
// 1. Define the shape of the context value
interface AuthContextValue {
user: User | null;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
// 2. Create the context (null as default — validated in the hook)
const AuthContext = createContext<AuthContextValue | null>(null);The Provider Component
The Provider wraps the part of the tree that needs access to the context:
function AuthProvider({ children }: { children: 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) throw new Error('Invalid credentials');
const userData = await response.json() as User;
setUser(userData);
}
function logout() {
setUser(null);
// Clear any tokens, redirect, etc.
}
const value: AuthContextValue = {
user,
isAuthenticated: user !== null,
login,
logout,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}The Consumer Hook
Always wrap useContext in a custom hook that validates the context is available:
function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error('useAuth must be called inside an AuthProvider');
}
return ctx;
}This gives you a clear error message if you forget to add the Provider, rather than a cryptic Cannot read properties of null error.
Consuming Context in Components
function UserMenu() {
const { user, isAuthenticated, logout } = useAuth();
if (!isAuthenticated) {
return <a href="/login">Sign in</a>;
}
return (
<div className="user-menu">
<img src={user!.avatarUrl} alt={user!.name} width={32} height={32} />
<span>{user!.name}</span>
<button type="button" onClick={logout}>Sign out</button>
</div>
);
}
function ProtectedPage() {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <p>Please log in to view this page.</p>;
}
return <main>Secret content</main>;
}Locale/Internationalization Context
type Locale = 'en' | 'es' | 'fr';
interface LocaleContextValue {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: string) => string;
}
const translations: Record<Locale, Record<string, string>> = {
en: { welcome: 'Welcome', logout: 'Sign out' },
es: { welcome: 'Bienvenido', logout: 'Cerrar sesión' },
fr: { welcome: 'Bienvenue', logout: 'Se déconnecter' },
};
const LocaleContext = createContext<LocaleContextValue | null>(null);
function LocaleProvider({ children }: { children: ReactNode }) {
const [locale, setLocale] = useState<Locale>('en');
function t(key: string): string {
return translations[locale][key] ?? key;
}
return (
<LocaleContext.Provider value={{ locale, setLocale, t }}>
{children}
</LocaleContext.Provider>
);
}
function useLocale(): LocaleContextValue {
const ctx = useContext(LocaleContext);
if (!ctx) throw new Error('useLocale must be inside LocaleProvider');
return ctx;
}
// Usage
function WelcomeBanner() {
const { t, locale, setLocale } = useLocale();
return (
<div>
<h1>{t('welcome')}</h1>
<select
value={locale}
onChange={(e) => setLocale(e.target.value as Locale)}
aria-label="Select language"
>
<option value="en">English</option>
<option value="es">Español</option>
<option value="fr">Français</option>
</select>
</div>
);
}Splitting Context for Performance
When a context holds multiple values that change at different rates, split it into separate contexts:
// Split into two contexts
const UserDataContext = createContext<User | null>(null);
const UserActionsContext = createContext<UserActions | null>(null);
function UserProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
// Actions object is stable — same reference every render
const actions: UserActions = useMemo(() => ({
updateName: (name: string) => setUser((u) => u ? { ...u, name } : u),
updateEmail: (email: string) => setUser((u) => u ? { ...u, email } : u),
}), []);
return (
<UserDataContext.Provider value={user}>
<UserActionsContext.Provider value={actions}>
{children}
</UserActionsContext.Provider>
</UserDataContext.Provider>
);
}
// Components that only dispatch actions won't re-render when user data changes
function EditButton() {
const actions = useContext(UserActionsContext)!;
return (
<button type="button" onClick={() => actions.updateName('New Name')}>
Edit
</button>
);
}Nesting Multiple Providers
In large applications, you compose multiple providers at the app root:
function App() {
return (
<AuthProvider>
<ThemeProvider>
<LocaleProvider>
<RouterProvider router={router} />
</LocaleProvider>
</ThemeProvider>
</AuthProvider>
);
}Consider creating a composed Providers component to keep App.tsx clean:
function Providers({ children }: { children: ReactNode }) {
return (
<AuthProvider>
<ThemeProvider>
<LocaleProvider>
{children}
</LocaleProvider>
</ThemeProvider>
</AuthProvider>
);
}In the next lesson, you will learn how to add client-side navigation to your app with React Router v7, including nested routes, dynamic params, and data loaders.
import {
createContext,
useContext,
useState,
useCallback,
type ReactNode,
} from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextValue {
theme: Theme;
resolvedTheme: 'light' | 'dark';
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
const stored = localStorage.getItem('theme') as Theme | null;
return stored ?? 'system';
});
const resolvedTheme: 'light' | 'dark' =
theme === 'system'
? window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
: theme;
const setTheme = useCallback((newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem('theme', newTheme);
}, []);
const toggleTheme = useCallback(() => {
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
}, [resolvedTheme, setTheme]);
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme, toggleTheme }}>
<div data-theme={resolvedTheme}>
{children}
</div>
</ThemeContext.Provider>
);
}
function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error('useTheme must be used inside ThemeProvider');
}
return ctx;
}
export { ThemeProvider, useTheme };
export type { Theme };
Sign in to track your progress