Skip to main content

useContext - Global State Management

What is Context?

Context provides a way to pass data through the component tree without having to pass props down manually at every level. It's designed to share data that can be considered "global" for a tree of React components.

The Problem: Prop Drilling

// ❌ Prop drilling - passing props through many levels
function App() {
const [user, setUser] = useState<User | null>(null);
return <Dashboard user={user} setUser={setUser} />;
}

function Dashboard({ user, setUser }) {
return <Sidebar user={user} setUser={setUser} />;
}

function Sidebar({ user, setUser }) {
return <UserMenu user={user} setUser={setUser} />;
}

function UserMenu({ user, setUser }) {
return <UserProfile user={user} setUser={setUser} />;
}

function UserProfile({ user, setUser }) {
return <div>{user?.name}</div>;
}

Creating Context

Basic Context

import { createContext, useContext, useState } from 'react';

// 1. Create context with default value
const UserContext = createContext<User | null>(null);

// 2. Create provider component
function UserProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);

return <UserContext.Provider value={user}>{children}</UserContext.Provider>;
}

// 3. Use context in components
function UserProfile() {
const user = useContext(UserContext);

if (!user) return <div>Not logged in</div>;
return <div>Welcome, {user.name}!</div>;
}

// 4. Wrap app with provider
function App() {
return (
<UserProvider>
<Dashboard />
</UserProvider>
);
}

Context with TypeScript

interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}

interface UserContextType {
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
updateUser: (updates: Partial<User>) => void;
}

// Create context with undefined default (safer)
const UserContext = createContext<UserContextType | undefined>(undefined);

function UserProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);

const login = async (email: string, password: string) => {
const user = await authAPI.login(email, password);
setUser(user);
};

const logout = () => {
setUser(null);
authAPI.logout();
};

const updateUser = (updates: Partial<User>) => {
if (user) {
setUser({ ...user, ...updates });
}
};

const value: UserContextType = {
user,
login,
logout,
updateUser,
};

return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}

// Custom hook for type safety
function useUser() {
const context = useContext(UserContext);
if (context === undefined) {
throw new Error('useUser must be used within UserProvider');
}
return context;
}

// Usage
function UserProfile() {
const { user, logout } = useUser();

return (
<div>
<h1>{user?.name}</h1>
<button onClick={logout}>Logout</button>
</div>
);
}

Multiple Contexts

// Theme Context
interface ThemeContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');

const toggleTheme = () => {
setTheme(prev => (prev === 'light' ? 'dark' : 'light'));
};

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

// Settings Context
interface Settings {
language: string;
notifications: boolean;
timezone: string;
}

const SettingsContext = createContext<
| {
settings: Settings;
updateSettings: (updates: Partial<Settings>) => void;
}
| undefined
>(undefined);

function SettingsProvider({ children }: { children: React.ReactNode }) {
const [settings, setSettings] = useState<Settings>({
language: 'en',
notifications: true,
timezone: 'UTC',
});

const updateSettings = (updates: Partial<Settings>) => {
setSettings(prev => ({ ...prev, ...updates }));
};

return (
<SettingsContext.Provider value={{ settings, updateSettings }}>
{children}
</SettingsContext.Provider>
);
}

// Combine providers
function App() {
return (
<UserProvider>
<ThemeProvider>
<SettingsProvider>
<Dashboard />
</SettingsProvider>
</ThemeProvider>
</UserProvider>
);
}

Provider Composition Pattern

// Create a combined provider
function AppProviders({ children }: { children: React.ReactNode }) {
return (
<UserProvider>
<ThemeProvider>
<SettingsProvider>
<NotificationProvider>{children}</NotificationProvider>
</SettingsProvider>
</ThemeProvider>
</UserProvider>
);
}

function App() {
return (
<AppProviders>
<Dashboard />
</AppProviders>
);
}

Advanced Patterns

Context with Reducer

type State = {
user: User | null;
loading: boolean;
error: string | null;
};

type Action =
| { type: 'LOGIN_START' }
| { type: 'LOGIN_SUCCESS'; payload: User }
| { type: 'LOGIN_ERROR'; payload: string }
| { type: 'LOGOUT' };

const initialState: State = {
user: null,
loading: false,
error: null,
};

function authReducer(state: State, action: Action): State {
switch (action.type) {
case 'LOGIN_START':
return { ...state, loading: true, error: null };
case 'LOGIN_SUCCESS':
return { user: action.payload, loading: false, error: null };
case 'LOGIN_ERROR':
return { ...state, loading: false, error: action.payload };
case 'LOGOUT':
return initialState;
default:
return state;
}
}

const AuthContext = createContext<
| {
state: State;
dispatch: React.Dispatch<Action>;
}
| undefined
>(undefined);

function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(authReducer, initialState);

return (
<AuthContext.Provider value={{ state, dispatch }}>
{children}
</AuthContext.Provider>
);
}

function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}

const { state, dispatch } = context;

const login = async (email: string, password: string) => {
dispatch({ type: 'LOGIN_START' });
try {
const user = await authAPI.login(email, password);
dispatch({ type: 'LOGIN_SUCCESS', payload: user });
} catch (error) {
dispatch({ type: 'LOGIN_ERROR', payload: error.message });
}
};

const logout = () => {
dispatch({ type: 'LOGOUT' });
};

return {
user: state.user,
loading: state.loading,
error: state.error,
login,
logout,
};
}

Context with Persistence

function createPersistedContext\<T\>(key: string, defaultValue: T) {
const Context = createContext<
| {
value: T;
setValue: (value: T) => void;
}
| undefined
>(undefined);

function Provider({ children }: { children: React.ReactNode }) {
const [value, setValue] = useState\<T\>(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : defaultValue;
});

useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [value]);

return (
<Context.Provider value={{ value, setValue }}>
{children}
</Context.Provider>
);
}

function useValue() {
const context = useContext(Context);
if (!context) {
throw new Error('useValue must be used within Provider');
}
return context;
}

return { Provider, useValue };
}

// Usage
const { Provider: ThemeProvider, useValue: useTheme } = createPersistedContext(
'theme',
'light'
);

function App() {
return (
<ThemeProvider>
<Dashboard />
</ThemeProvider>
);
}

function ThemeToggle() {
const { value: theme, setValue: setTheme } = useTheme();
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
);
}

Selective Context Updates

// Split context to prevent unnecessary re-renders
const UserStateContext = createContext<User | null>(null);
const UserActionsContext = createContext<
| {
updateUser: (updates: Partial<User>) => void;
logout: () => void;
}
| undefined
>(undefined);

function UserProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);

const actions = useMemo(
() => ({
updateUser: (updates: Partial<User>) => {
setUser(prev => (prev ? { ...prev, ...updates } : null));
},
logout: () => {
setUser(null);
},
}),
[]
);

return (
<UserStateContext.Provider value={user}>
<UserActionsContext.Provider value={actions}>
{children}
</UserActionsContext.Provider>
</UserStateContext.Provider>
);
}

// Hooks to access specific parts
function useUserState() {
const context = useContext(UserStateContext);
if (context === undefined) {
throw new Error('useUserState must be used within UserProvider');
}
return context;
}

function useUserActions() {
const context = useContext(UserActionsContext);
if (context === undefined) {
throw new Error('useUserActions must be used within UserProvider');
}
return context;
}

// Component only re-renders when actions are called, not when user state changes
function LogoutButton() {
const { logout } = useUserActions();
return <button onClick={logout}>Logout</button>;
}

// Component re-renders when user changes
function UserDisplay() {
const user = useUserState();
return <div>{user?.name}</div>;
}

Real-World Examples

Shopping Cart Context

interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}

interface CartContextType {
items: CartItem[];
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
total: number;
}

const CartContext = createContext<CartContextType | undefined>(undefined);

function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);

const addItem = (item: Omit<CartItem, 'quantity'>) => {
setItems(prev => {
const existing = prev.find(i => i.id === item.id);
if (existing) {
return prev.map(i =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
);
}
return [...prev, { ...item, quantity: 1 }];
});
};

const removeItem = (id: string) => {
setItems(prev => prev.filter(item => item.id !== id));
};

const updateQuantity = (id: string, quantity: number) => {
if (quantity <= 0) {
removeItem(id);
return;
}
setItems(prev =>
prev.map(item => (item.id === id ? { ...item, quantity } : item))
);
};

const clearCart = () => {
setItems([]);
};

const total = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);

return (
<CartContext.Provider
value={{ items, addItem, removeItem, updateQuantity, clearCart, total }}
>
{children}
</CartContext.Provider>
);
}

export function useCart() {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart must be used within CartProvider');
}
return context;
}

Performance Optimization

Memoize Context Value

function UserProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);

// ❌ New object on every render
return (
<UserContext.Provider value={{ user, setUser }}>
{children}
</UserContext.Provider>
);

// ✅ Memoized value
const value = useMemo(() => ({ user, setUser }), [user]);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}

Split Contexts for Performance

// Instead of one large context
// Split into smaller, focused contexts
<UserProvider>
<ThemeProvider>
<NotificationProvider>{children}</NotificationProvider>
</ThemeProvider>
</UserProvider>

Best Practices

  1. Create custom hooks - Always wrap useContext in a custom hook
  2. Validate context - Throw error if used outside provider
  3. Memoize values - Prevent unnecessary re-renders
  4. Split contexts - Keep contexts focused and small
  5. Use TypeScript - Type safety for context values
  6. Avoid overuse - Don't use context for every piece of state
  7. Consider alternatives - Props, composition, state management libraries

When NOT to Use Context

  • ❌ Frequently changing data (use state management library)
  • ❌ Local component state (use useState)
  • ❌ Props that are only passed down 1-2 levels
  • ❌ Performance-critical applications with many updates

Practice Exercises

  1. Create a theme context with light/dark mode
  2. Build an authentication context with login/logout
  3. Implement a notification/toast context
  4. Create a multi-language i18n context
  5. Build a shopping cart context

Next Steps