Skip to content

React Context

Context provides a way to pass data through the component tree without manually threading props at every level. Use it for global state like the current user, theme, or locale.

Context suits data that many components at different nesting levels need:

  • Authentication state (current user)
  • UI theme (dark/light)
  • Locale / language
  • Feature flags

Don’t use Context for state that is local to a subtree — useState with prop passing is simpler.

src/context/ThemeContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextValue {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
const toggleTheme = () =>
setTheme(prev => (prev === 'light' ? 'dark' : 'light'));
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
const context = useContext(ThemeContext);
if (!context) throw new Error('useTheme must be used inside ThemeProvider');
return context;
}

Wrap the relevant part of the tree (usually the app root):

src/main.tsx
import { ThemeProvider } from './context/ThemeContext';
function App() {
return (
<ThemeProvider>
<Router />
</ThemeProvider>
);
}
import { useTheme } from '../context/ThemeContext';
function Header() {
const { theme, toggleTheme } = useTheme();
return (
<header className={theme}>
<button onClick={toggleTheme}>
Switch to {theme === 'light' ? 'dark' : 'light'} mode
</button>
</header>
);
}

The most common real-world use:

interface User {
id: string;
name: string;
email: string;
}
interface AuthContextValue {
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Check for existing session on mount
checkSession().then(setUser).finally(() => setIsLoading(false));
}, []);
const login = async (email: string, password: string) => {
const user = await authApi.login(email, password);
setUser(user);
};
const logout = () => {
authApi.logout();
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout, isLoading }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used inside AuthProvider');
return context;
};

Compose multiple providers at the root — order matters only if one depends on another:

function App() {
return (
<AuthProvider>
<ThemeProvider>
<RouterProvider router={router} />
</ThemeProvider>
</AuthProvider>
);
}
ScenarioRecommended
Simple global state (theme, user)Context
Frequent updates (counters, forms)Zustand / Redux
Complex state transitionsRedux Toolkit
Server stateTanStack Query

Context re-renders all consumers when the value changes. For frequently-updating state, prefer a dedicated state management library to avoid unnecessary renders.

Split contexts by update frequency:

// Split into separate contexts so consumers only re-render when their data changes
const UserContext = createContext<User | null>(null);
const UserActionsContext = createContext<{ logout: () => void } | null>(null);

Or memoize the context value:

const value = useMemo(() => ({ theme, toggleTheme }), [theme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;