π useContext Hook
Welcome to one of React's most powerful features for sharing data! Have you ever found yourself passing props down through multiple layers of components, just to get data to a deeply nested child? That's called "prop drilling," and it's a pain. The useContext Hook solves this problem elegantly by creating a "wormhole" for your data - allowing any component to access shared values without passing props through every level. Think of it like a radio broadcast: instead of passing a message person-to-person through a chain, you broadcast it, and anyone tuned in can receive it. Let's learn how to use this superpower wisely! π‘
π― Learning Objectives
By the end of this lesson, you will be able to:
- Understand the prop drilling problem and why it matters
- Create a Context with proper TypeScript typing
- Provide context values to component trees
- Consume context using the useContext Hook
- Implement common context patterns (theme, authentication, etc.)
- Combine useContext with useReducer for state management
- Understand when to use context vs props
- Avoid common context pitfalls and performance issues
- Structure context for scalable applications
Estimated Time: 75-90 minutes
Project: Build a theme switcher and authentication context
π In This Lesson
βοΈ The Prop Drilling Problem
Before we dive into Context, let's understand the problem it solves. Prop drilling occurs when you need to pass data through many layers of components to reach a deeply nested child.
A Real-World Scenario
Imagine you're building an app with user information that needs to be accessed by components at different levels:
The Prop Drilling Approach
Without Context, you'd have to pass the user data through every component in the chain:
// β Prop drilling - passing through components that don't need the data
interface User {
id: number;
name: string;
email: string;
}
// App component - has the user data
function App() {
const [user, setUser] = useState<User>({
id: 1,
name: 'John Doe',
email: 'john@example.com'
});
return <Dashboard user={user} />;
}
// Dashboard - doesn't use user, just passes it along
function Dashboard({ user }: { user: User }) {
return (
<div>
<Sidebar user={user} />
<MainContent user={user} />
</div>
);
}
// Sidebar - doesn't use user, just passes it along
function Sidebar({ user }: { user: User }) {
return (
<div>
<UserMenu user={user} />
</div>
);
}
// UserMenu - doesn't use user, just passes it along
function UserMenu({ user }: { user: User }) {
return <UserProfile user={user} />;
}
// UserProfile - FINALLY uses the user data!
function UserProfile({ user }: { user: User }) {
return <div>{user.name}</div>;
}
β οΈ Problems with Prop Drilling
- Verbose - Lots of repetitive prop declarations
- Fragile - Adding props requires updating every component in the chain
- Confusing - Components receive props they don't use
- Hard to Refactor - Moving components means updating all props
- Tight Coupling - Intermediate components depend on data they don't need
The Visual Problem
Here's what prop drilling looks like visually:
need user, but must
pass it through
π‘ When Does Prop Drilling Become a Problem?
- Data needs to pass through 3+ component levels
- Many components need the same data
- Intermediate components don't use the data
- The data changes frequently
- You find yourself adding props to components just to pass them down
Real-World Examples
Common scenarios where prop drilling becomes painful:
| Scenario | What Needs Sharing | Who Needs It |
|---|---|---|
| Theme | Dark/light mode preference | Every styled component |
| Authentication | Current user, login status | Protected routes, user menus, etc. |
| Language | Current locale (en, es, fr) | All text-displaying components |
| Shopping Cart | Cart items, total, quantity | Product pages, cart icon, checkout |
| Settings | User preferences | Various UI components |
π Introduction to Context
React Context is the solution to prop drilling. It provides a way to share values between components without explicitly passing props through every level of the tree.
π Definition
Context: A feature in React that provides a way to pass data through the component tree without having to pass props down manually at every level. It creates a "global" scope for a specific part of your component tree.
The Context Solution
With Context, components can "broadcast" data, and any component in the tree can "tune in" to receive it:
Notice how Dashboard, Sidebar, and UserMenu don't need to pass the user prop anymore! The components that need the data can directly access it from context.
How Context Works
Context has three main parts:
The Three Pieces of Context
-
Create Context - Define what data will be shared
const UserContext = createContext(defaultValue); -
Provide Context - Wrap components that need access
<UserContext.Provider value={user}> {children} </UserContext.Provider> -
Consume Context - Read the value in components
const user = useContext(UserContext);
Context Flow Visualization
Real-World Analogy
Think of Context like a radio broadcast system:
| Radio Broadcast | React Context |
|---|---|
| Radio Station | Context Provider |
| Broadcast Signal | Context Value |
| Radio Receiver | useContext Hook |
| Tune to Frequency | Import and use context |
| Broadcast Range | Provider's component tree |
Just like how any radio in range can tune into a station without being physically connected, any component within a Provider can access the context value without receiving props!
β Benefits of Context
- No Prop Drilling - Skip intermediate components
- Cleaner Code - Less boilerplate, more focused components
- Easier Refactoring - Move components without updating props
- Shared State - Multiple components access same data
- Loose Coupling - Components only depend on data they use
- Better Organization - Group related data together
β οΈ When NOT to Use Context
Context isn't always the answer. Don't use it when:
- Data only needs to go 1-2 levels down (use props)
- You're just trying to avoid typing props (that's what props are for!)
- The data changes very frequently and affects many components (may cause performance issues)
- The data is only used by a single component
- You need to optimize re-renders carefully (Context re-renders all consumers)
Context vs Props vs State Management
| Solution | Use When | Example |
|---|---|---|
| Props | Parent-child communication, 1-2 levels | Passing onClick to a button |
| Context | Data needed by many components, deep nesting | Theme, auth, language |
| State Management (Redux, Zustand) |
Complex global state, time travel, middleware | Large e-commerce app state |
π¨ Creating Context
Let's learn how to create Context step by step. We'll use TypeScript to ensure type safety throughout.
Basic Context Creation
The first step is to create a context using createContext:
import { createContext } from 'react';
// Simple value context
const ThemeContext = createContext('light');
// Object context
const UserContext = createContext({
name: 'Guest',
isLoggedIn: false
});
createContext takes a default value that's used when a component tries to access context without a Provider. This default is mostly useful for testing.
Context with TypeScript
For TypeScript, we need to properly type our context:
import { createContext } from 'react';
// Define the shape of your context value
interface User {
id: number;
name: string;
email: string;
}
// Create context with type and default value
const UserContext = createContext<User>({
id: 0,
name: 'Guest',
email: ''
});
π‘ TypeScript Tip
The generic <User> tells TypeScript what type of value the context holds. This gives you autocomplete and type checking when using the context!
Context with Functions
Context can hold more than just data - it can include functions too:
import { createContext } from 'react';
interface ThemeContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType>({
theme: 'light',
toggleTheme: () => {
console.warn('toggleTheme called outside of Provider');
}
});
Context with Undefined Default
Sometimes you don't want a meaningful default value. Use undefined and force components to check:
import { createContext } from 'react';
interface User {
id: number;
name: string;
}
// Allow undefined - requires Provider
const UserContext = createContext<User | undefined>(undefined);
// Later, when consuming:
const user = useContext(UserContext);
if (!user) {
throw new Error('useUser must be used within UserProvider');
}
β When to Use Undefined Default
Use undefined as default when:
- Context must always be used within a Provider
- You want to catch mistakes early (forgetting Provider)
- A fake default value doesn't make sense
- You want to enforce proper setup
File Organization
Best practice is to create contexts in separate files:
// src/contexts/UserContext.tsx
import { createContext } from 'react';
export interface User {
id: number;
name: string;
email: string;
}
export interface UserContextType {
user: User | null;
login: (user: User) => void;
logout: () => void;
}
export const UserContext = createContext<UserContextType | undefined>(
undefined
);
This keeps your contexts organized and makes them easy to import:
// In any component
import { UserContext } from '@/contexts/UserContext';
import { useContext } from 'react';
function MyComponent() {
const userContext = useContext(UserContext);
// ...
}
π€ Providing Context Values
Creating a context is just the first step. To make the context value available to components, you need to wrap them in a Provider. Think of the Provider as the broadcasting station that transmits the signal.
Basic Provider Usage
Every context comes with a Provider component that accepts a value prop:
import { createContext } from 'react';
const ThemeContext = createContext('light');
function App() {
return (
<ThemeContext.Provider value="dark">
<Header />
<MainContent />
<Footer />
</ThemeContext.Provider>
);
}
// Now Header, MainContent, Footer (and all their children)
// can access the theme value "dark"
π‘ Provider Scope
The Provider's scope is its entire component tree. Any component inside the Provider (at any nesting level) can access the context value. Components outside the Provider cannot.
Provider with State
Usually, you'll want the context value to be dynamic, not static. Use state in the Provider:
import { createContext, useState } from 'react';
interface User {
id: number;
name: string;
}
const UserContext = createContext<User | null>(null);
function App() {
const [user, setUser] = useState<User | null>(null);
return (
<UserContext.Provider value={user}>
<Dashboard />
</UserContext.Provider>
);
}
// Now the user state is accessible throughout the Dashboard tree
Provider with Multiple Values
You can provide multiple related values by wrapping them in an object:
import { createContext, useState } from 'react';
interface User {
id: number;
name: string;
}
interface UserContextType {
user: User | null;
login: (user: User) => void;
logout: () => void;
}
const UserContext = createContext<UserContextType | undefined>(undefined);
function App() {
const [user, setUser] = useState<User | null>(null);
const login = (newUser: User) => {
setUser(newUser);
};
const logout = () => {
setUser(null);
};
const contextValue = {
user,
login,
logout
};
return (
<UserContext.Provider value={contextValue}>
<Dashboard />
</UserContext.Provider>
);
}
β οΈ Provider Value Warning
Be careful with the value prop! If you pass an object literal or create a new object on every render, you'll cause unnecessary re-renders:
// β Bad - creates new object every render
<UserContext.Provider value={{ user, login, logout }}>
// β
Good - stable reference
const contextValue = { user, login, logout };
<UserContext.Provider value={contextValue}>
We'll cover this more in the performance section!
Custom Provider Component Pattern
A common pattern is to create a custom Provider component that encapsulates the logic:
import { createContext, useState, ReactNode } from 'react';
interface User {
id: number;
name: string;
}
interface UserContextType {
user: User | null;
login: (user: User) => void;
logout: () => void;
}
export const UserContext = createContext<UserContextType | undefined>(undefined);
// Custom Provider component
export function UserProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = (newUser: User) => {
setUser(newUser);
localStorage.setItem('user', JSON.stringify(newUser));
};
const logout = () => {
setUser(null);
localStorage.removeItem('user');
};
const value = { user, login, logout };
return (
<UserContext.Provider value={value}>
{children}
</UserContext.Provider>
);
}
// Now use the custom Provider
function App() {
return (
<UserProvider>
<Dashboard />
</UserProvider>
);
}
β Custom Provider Benefits
- Encapsulation - Logic lives with the context
- Reusability - Easy to use across apps
- Cleaner App Component - Less clutter in main component
- Single Responsibility - Provider handles one concern
- Easier Testing - Test provider independently
Multiple Providers
You can nest multiple Providers for different contexts:
function App() {
return (
<ThemeProvider>
<UserProvider>
<LanguageProvider>
<Dashboard />
</LanguageProvider>
</UserProvider>
</ThemeProvider>
);
}
// Dashboard and all children can access theme, user, and language contexts
π‘ Provider Order
The order of Providers usually doesn't matter, unless one Provider depends on another's context. Put independent Providers in any order, but if ThemeProvider needs user data, put UserProvider outside.
Scoped Providers
You can have multiple Providers of the same context in different parts of your tree:
function App() {
return (
<div>
{/* Light theme section */}
<ThemeContext.Provider value="light">
<Header />
</ThemeContext.Provider>
{/* Dark theme section */}
<ThemeContext.Provider value="dark">
<MainContent />
</ThemeContext.Provider>
{/* Uses default or nearest Provider */}
<Footer />
</div>
);
}
// Header uses light theme, MainContent uses dark theme
Components use the value from the nearest Provider above them in the tree.
Provider Placement Strategy
π₯ Consuming Context
Now that we know how to provide context, let's learn how to consume it using the useContext Hook. This is where the magic happens!
Basic useContext Usage
The useContext Hook reads the value from the nearest Provider above it:
import { useContext } from 'react';
import { ThemeContext } from './ThemeContext';
function Header() {
const theme = useContext(ThemeContext);
return (
<header className={theme === 'dark' ? 'dark' : 'light'}>
<h1>My App</h1>
</header>
);
}
π‘ How useContext Works
- Import the context you want to use
- Call useContext with that context
- Get back the current value from the Provider
- Use the value in your component
Consuming Object Context
When your context provides an object with multiple values:
import { useContext } from 'react';
import { UserContext } from './UserContext';
function UserProfile() {
const userContext = useContext(UserContext);
// Destructure for cleaner code
const { user, login, logout } = userContext;
if (!user) {
return (
<button onClick={() => login({ id: 1, name: 'John' })}>
Login
</button>
);
}
return (
<div>
<p>Welcome, {user.name}!</p>
<button onClick={logout}>Logout</button>
</div>
);
}
Multiple Context Consumption
A component can consume multiple contexts:
import { useContext } from 'react';
import { ThemeContext } from './ThemeContext';
import { UserContext } from './UserContext';
import { LanguageContext } from './LanguageContext';
function Dashboard() {
const theme = useContext(ThemeContext);
const { user } = useContext(UserContext);
const { language } = useContext(LanguageContext);
return (
<div className={`dashboard ${theme}`}>
<h1>{language === 'en' ? 'Dashboard' : 'Tablero'}</h1>
<p>User: {user?.name}</p>
</div>
);
}
Custom Hook Pattern
A best practice is to create a custom hook for each context:
// UserContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
interface User {
id: number;
name: string;
}
interface UserContextType {
user: User | null;
login: (user: User) => void;
logout: () => void;
}
const UserContext = createContext<UserContextType | undefined>(undefined);
// Custom Provider
export function UserProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = (newUser: User) => setUser(newUser);
const logout = () => setUser(null);
return (
<UserContext.Provider value={{ user, login, logout }}>
{children}
</UserContext.Provider>
);
}
// Custom Hook - this is the magic!
export function useUser() {
const context = useContext(UserContext);
if (context === undefined) {
throw new Error('useUser must be used within UserProvider');
}
return context;
}
// Now in components:
function MyComponent() {
const { user, login, logout } = useUser(); // Clean and type-safe!
// ...
}
β Custom Hook Benefits
- Error Checking - Catches missing Provider at runtime
- Type Safety - No undefined checks needed
- Cleaner Imports - Import one hook instead of context + useContext
- Consistent API - Every context has the same usage pattern
- Better DX - Autocomplete works perfectly
Context with Computed Values
You can derive values based on context in the consuming component:
function TodoStats() {
const { todos } = useTodos();
// Derive values from context
const totalTodos = todos.length;
const completedTodos = todos.filter(t => t.completed).length;
const activeTodos = totalTodos - completedTodos;
const completionRate = totalTodos > 0
? Math.round((completedTodos / totalTodos) * 100)
: 0;
return (
<div>
<p>Total: {totalTodos}</p>
<p>Completed: {completedTodos}</p>
<p>Active: {activeTodos}</p>
<p>Completion: {completionRate}%</p>
</div>
);
}
Conditional Context Usage
Sometimes you want to use context only if it's available:
// Optional context hook
export function useOptionalUser() {
return useContext(UserContext);
}
function UserGreeting() {
const userContext = useOptionalUser();
// Handle both cases: inside and outside Provider
if (!userContext || !userContext.user) {
return <p>Welcome, Guest!</p>;
}
return <p>Welcome, {userContext.user.name}!</p>;
}
β οΈ Common Mistake
Don't call useContext conditionally or in loops:
// β Wrong - breaks Rules of Hooks
if (someCondition) {
const theme = useContext(ThemeContext);
}
// β
Correct - call at top level, use conditionally
const theme = useContext(ThemeContext);
if (someCondition) {
// use theme
}
Context Consumer Flow
π· Typing Context with TypeScript
TypeScript makes Context even more powerful by ensuring type safety. Let's explore the best patterns for typing contexts.
Basic Context Typing
The simplest approach - define an interface and use it:
import { createContext } from 'react';
// Define the shape of your context
interface ThemeContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
// Create context with type
const ThemeContext = createContext<ThemeContextType>({
theme: 'light',
toggleTheme: () => {}
});
Context with Undefined (Recommended)
The safest pattern - force Provider usage with undefined default:
import { createContext, useContext } from 'react';
interface User {
id: number;
name: string;
email: string;
}
interface UserContextType {
user: User | null;
setUser: (user: User | null) => void;
}
// Create context that allows undefined
const UserContext = createContext<UserContextType | undefined>(undefined);
// Custom hook with type guard
export function useUser(): UserContextType {
const context = useContext(UserContext);
if (context === undefined) {
throw new Error('useUser must be used within UserProvider');
}
// Now TypeScript knows context is UserContextType, not undefined!
return context;
}
β Why This Pattern is Best
- Runtime Safety - Catches missing Provider immediately
- No Fake Defaults - Don't need meaningless default values
- Type Safety - Custom hook returns non-undefined type
- Clear Errors - Helpful error message for developers
Complete Typed Context Example
Here's a full example with all TypeScript best practices:
// UserContext.tsx
import {
createContext,
useContext,
useState,
ReactNode,
useCallback
} from 'react';
// 1. Define data types
export interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user';
}
// 2. Define context type
interface UserContextType {
user: User | null;
isLoggedIn: boolean;
login: (user: User) => void;
logout: () => void;
updateUser: (updates: Partial<User>) => void;
}
// 3. Create context with undefined default
const UserContext = createContext<UserContextType | undefined>(undefined);
// 4. Provider props type
interface UserProviderProps {
children: ReactNode;
}
// 5. Custom Provider component
export function UserProvider({ children }: UserProviderProps) {
const [user, setUser] = useState<User | null>(null);
const login = useCallback((newUser: User) => {
setUser(newUser);
localStorage.setItem('user', JSON.stringify(newUser));
}, []);
const logout = useCallback(() => {
setUser(null);
localStorage.removeItem('user');
}, []);
const updateUser = useCallback((updates: Partial<User>) => {
setUser(current => {
if (!current) return null;
return { ...current, ...updates };
});
}, []);
const value: UserContextType = {
user,
isLoggedIn: user !== null,
login,
logout,
updateUser
};
return (
<UserContext.Provider value={value}>
{children}
</UserContext.Provider>
);
}
// 6. Custom hook with type guard
export function useUser(): UserContextType {
const context = useContext(UserContext);
if (context === undefined) {
throw new Error('useUser must be used within UserProvider');
}
return context;
}
// 7. Optional: Additional utility hooks
export function useIsAdmin(): boolean {
const { user } = useUser();
return user?.role === 'admin';
}
export function useCurrentUserId(): number | null {
const { user } = useUser();
return user?.id ?? null;
}
π‘ TypeScript Features Used
- Interface - Define shape of User and context
- Union Types -
User | nullfor optional user - Literal Types -
'admin' | 'user'for role - Partial Type -
Partial<User>for updates - ReactNode - Type for children prop
- Type Guard - Error throwing removes undefined
Generic Context Pattern
For reusable contexts, use generics:
import { createContext, useContext, useState, ReactNode } from 'react';
// Generic state context creator
function createStateContext<T>(defaultValue: T) {
const Context = createContext<{
value: T;
setValue: (value: T) => void;
} | undefined>(undefined);
function Provider({ children }: { children: ReactNode }) {
const [value, setValue] = useState<T>(defaultValue);
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] as const;
}
// Usage - create typed contexts easily!
const [CountProvider, useCount] = createStateContext(0);
const [NameProvider, useName] = createStateContext('');
const [UserProvider, useUser] = createStateContext<User | null>(null);
Typing Context Actions
When combining with useReducer, type your actions carefully:
import { createContext, useContext, useReducer, ReactNode } from 'react';
// State type
interface Todo {
id: number;
text: string;
completed: boolean;
}
interface TodoState {
todos: Todo[];
}
// Action types
type TodoAction =
| { type: 'ADD_TODO'; payload: { text: string } }
| { type: 'TOGGLE_TODO'; payload: { id: number } }
| { type: 'DELETE_TODO'; payload: { id: number } };
// Context type
interface TodoContextType {
state: TodoState;
dispatch: React.Dispatch<TodoAction>;
}
const TodoContext = createContext<TodoContextType | undefined>(undefined);
// Reducer
function todoReducer(state: TodoState, action: TodoAction): TodoState {
// Reducer implementation
return state;
}
// Provider
export function TodoProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(todoReducer, { todos: [] });
return (
<TodoContext.Provider value={{ state, dispatch }}>
{children}
</TodoContext.Provider>
);
}
// Custom hook
export function useTodos(): TodoContextType {
const context = useContext(TodoContext);
if (!context) {
throw new Error('useTodos must be used within TodoProvider');
}
return context;
}
π¨ Theme Context Example
Let's build a complete, real-world example: a theme switcher that allows users to toggle between light and dark modes. This will demonstrate all the concepts we've learned so far.
Project Structure
We'll create a theme context with the following features:
Features We'll Build
- π Light and dark theme modes
- π Toggle between themes
- πΎ Persist preference to localStorage
- π¨ Apply theme styles to entire app
- π· Full TypeScript typing
Step 1: Define Types and Create Context
// src/contexts/ThemeContext.tsx
import { createContext, useContext } from 'react';
// Define theme type
export type Theme = 'light' | 'dark';
// Define context type
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
setTheme: (theme: Theme) => void;
}
// Create context
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
Step 2: Create the Provider
// Continue in ThemeContext.tsx
import { useState, useEffect, ReactNode } from 'react';
interface ThemeProviderProps {
children: ReactNode;
}
export function ThemeProvider({ children }: ThemeProviderProps) {
// Initialize theme from localStorage or default to 'light'
const [theme, setTheme] = useState<Theme>(() => {
const savedTheme = localStorage.getItem('theme');
return (savedTheme as Theme) || 'light';
});
// Save theme to localStorage when it changes
useEffect(() => {
localStorage.setItem('theme', theme);
// Apply theme to document for CSS
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
// Toggle between light and dark
const toggleTheme = () => {
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
};
const value: ThemeContextType = {
theme,
toggleTheme,
setTheme
};
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
Step 3: Create Custom Hook
// Continue in ThemeContext.tsx
// Custom hook with error checking
export function useTheme(): ThemeContextType {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
// Complete ThemeContext.tsx file is now ready!
Step 4: Add CSS Styles
/* src/styles/theme.css */
/* Light theme (default) */
:root,
[data-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--text-primary: #333333;
--text-secondary: #666666;
--border-color: #dddddd;
--accent-color: #667eea;
--card-bg: #ffffff;
--shadow: rgba(0, 0, 0, 0.1);
}
/* Dark theme */
[data-theme="dark"] {
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--border-color: #404040;
--accent-color: #8b9bff;
--card-bg: #252525;
--shadow: rgba(0, 0, 0, 0.5);
}
/* Apply theme variables */
body {
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.3s ease, color 0.3s ease;
}
.card {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
box-shadow: 0 2px 8px var(--shadow);
}
.button {
background-color: var(--accent-color);
color: white;
}
Step 5: Wrap App with Provider
// src/main.tsx or App.tsx
import { ThemeProvider } from './contexts/ThemeContext';
import './styles/theme.css';
function App() {
return (
<ThemeProvider>
<Header />
<MainContent />
<Footer />
</ThemeProvider>
);
}
export default App;
Step 6: Use Theme in Components
// src/components/Header.tsx
import { useTheme } from '../contexts/ThemeContext';
function Header() {
const { theme, toggleTheme } = useTheme();
return (
<header>
<h1>My App</h1>
<button onClick={toggleTheme}>
{theme === 'light' ? 'π' : 'βοΈ'}
{theme === 'light' ? ' Dark Mode' : ' Light Mode'}
</button>
</header>
);
}
export default Header;
Step 7: Create Theme Toggle Component
// src/components/ThemeToggle.tsx
import { useTheme } from '../contexts/ThemeContext';
import './ThemeToggle.css';
function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button
className="theme-toggle"
onClick={toggleTheme}
aria-label="Toggle theme"
>
<span className="theme-toggle-icon">
{theme === 'light' ? 'π' : 'βοΈ'}
</span>
<span className="theme-toggle-text">
{theme === 'light' ? 'Dark' : 'Light'}
</span>
</button>
);
}
export default ThemeToggle;
/* ThemeToggle.css */
.theme-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.theme-toggle:hover {
background-color: var(--accent-color);
color: white;
transform: translateY(-2px);
}
.theme-toggle-icon {
font-size: 1.2rem;
}
.theme-toggle-text {
font-weight: 500;
}
Complete Example with Multiple Components
// src/components/Dashboard.tsx
import { useTheme } from '../contexts/ThemeContext';
function Dashboard() {
const { theme } = useTheme();
return (
<div className="dashboard">
<h1>Dashboard</h1>
<p>Current theme: {theme}</p>
<div className="card">
<h2>Stats</h2>
<p>This card uses theme variables for styling!</p>
</div>
<div className="card">
<h2>Activity</h2>
<p>The theme changes apply to all components automatically.</p>
</div>
</div>
);
}
// src/components/Settings.tsx
function Settings() {
const { theme, setTheme } = useTheme();
return (
<div className="settings">
<h2>Settings</h2>
<div className="setting-group">
<label>Theme Preference:</label>
<select
value={theme}
onChange={(e) => setTheme(e.target.value as 'light' | 'dark')}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</div>
);
}
β What We Accomplished
- Context Creation - Defined theme context with TypeScript
- Provider Setup - Created provider with localStorage persistence
- Custom Hook - Made useTheme hook with error checking
- CSS Variables - Used data attributes for theme styling
- Multiple Components - Any component can access theme
- Persistence - Theme survives page refresh
- Smooth Transitions - CSS transitions for theme changes
Enhanced Theme with System Preference
Let's add detection of the user's system theme preference:
// Enhanced ThemeProvider
export function ThemeProvider({ children }: ThemeProviderProps) {
// Get system preference
const getSystemTheme = (): Theme => {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
};
// Initialize with saved theme, or system preference, or default
const [theme, setTheme] = useState<Theme>(() => {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
return savedTheme as Theme;
}
return getSystemTheme();
});
// Listen for system theme changes
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
if (!localStorage.getItem('theme')) {
// Only update if user hasn't set a preference
setTheme(e.matches ? 'dark' : 'light');
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
// Rest of the provider code...
}
π‘ Advanced Features Added
- System Preference Detection - Uses prefers-color-scheme
- Automatic Updates - Responds to system theme changes
- User Preference Priority - Manual choice overrides system
- Smart Defaults - Falls back gracefully
βοΈ Context with useReducer
Combining Context with useReducer creates a powerful state management solution. This pattern is perfect for complex state that needs to be accessed by many components. Let's build a todo application to see this in action!
Why Combine Context + useReducer?
The Power Combination
- useReducer - Manages complex state logic in one place
- Context - Makes state and dispatch available everywhere
- Together - Global state management without Redux!
Complete Todo Context Example
Step 1: Define Types
// src/contexts/TodoContext.tsx
import { createContext, useContext, useReducer, ReactNode } from 'react';
// Todo type
export interface Todo {
id: number;
text: string;
completed: boolean;
}
// State type
interface TodoState {
todos: Todo[];
filter: 'all' | 'active' | 'completed';
}
// Action types
type TodoAction =
| { type: 'ADD_TODO'; payload: { text: string } }
| { type: 'TOGGLE_TODO'; payload: { id: number } }
| { type: 'DELETE_TODO'; payload: { id: number } }
| { type: 'EDIT_TODO'; payload: { id: number; text: string } }
| { type: 'SET_FILTER'; payload: { filter: 'all' | 'active' | 'completed' } }
| { type: 'CLEAR_COMPLETED' };
Step 2: Create Reducer
// Continue in TodoContext.tsx
function todoReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [
...state.todos,
{
id: Date.now(),
text: action.payload.text,
completed: false
}
]
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload.id
? { ...todo, completed: !todo.completed }
: todo
)
};
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.payload.id)
};
case 'EDIT_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload.id
? { ...todo, text: action.payload.text }
: todo
)
};
case 'SET_FILTER':
return {
...state,
filter: action.payload.filter
};
case 'CLEAR_COMPLETED':
return {
...state,
todos: state.todos.filter(todo => !todo.completed)
};
default:
return state;
}
}
Step 3: Create Context and Provider
// Continue in TodoContext.tsx
// Context type
interface TodoContextType {
state: TodoState;
dispatch: React.Dispatch<TodoAction>;
}
// Create context
const TodoContext = createContext<TodoContextType | undefined>(undefined);
// Provider component
export function TodoProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(todoReducer, {
todos: [],
filter: 'all'
});
return (
<TodoContext.Provider value={{ state, dispatch }}>
{children}
</TodoContext.Provider>
);
}
// Custom hook
export function useTodos() {
const context = useContext(TodoContext);
if (context === undefined) {
throw new Error('useTodos must be used within TodoProvider');
}
return context;
}
Step 4: Create Action Creators
// Continue in TodoContext.tsx
// Action creators for cleaner usage
export const todoActions = {
addTodo: (text: string): TodoAction => ({
type: 'ADD_TODO',
payload: { text }
}),
toggleTodo: (id: number): TodoAction => ({
type: 'TOGGLE_TODO',
payload: { id }
}),
deleteTodo: (id: number): TodoAction => ({
type: 'DELETE_TODO',
payload: { id }
}),
editTodo: (id: number, text: string): TodoAction => ({
type: 'EDIT_TODO',
payload: { id, text }
}),
setFilter: (filter: 'all' | 'active' | 'completed'): TodoAction => ({
type: 'SET_FILTER',
payload: { filter }
}),
clearCompleted: (): TodoAction => ({
type: 'CLEAR_COMPLETED'
})
};
Step 5: Use in Components
// src/components/AddTodo.tsx
import { useState } from 'react';
import { useTodos, todoActions } from '../contexts/TodoContext';
function AddTodo() {
const { dispatch } = useTodos();
const [text, setText] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (text.trim()) {
dispatch(todoActions.addTodo(text));
setText('');
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="What needs to be done?"
/>
<button type="submit">Add</button>
</form>
);
}
export default AddTodo;
// src/components/TodoList.tsx
import { useTodos, todoActions } from '../contexts/TodoContext';
import TodoItem from './TodoItem';
function TodoList() {
const { state, dispatch } = useTodos();
// Filter todos based on current filter
const filteredTodos = state.todos.filter(todo => {
if (state.filter === 'active') return !todo.completed;
if (state.filter === 'completed') return todo.completed;
return true; // 'all'
});
return (
<div>
<ul className="todo-list">
{filteredTodos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
{filteredTodos.length === 0 && (
<p className="empty-state">No todos to show</p>
)}
</div>
);
}
export default TodoList;
// src/components/TodoItem.tsx
import { useTodos, todoActions, Todo } from '../contexts/TodoContext';
interface TodoItemProps {
todo: Todo;
}
function TodoItem({ todo }: TodoItemProps) {
const { dispatch } = useTodos();
return (
<li className={todo.completed ? 'completed' : ''}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => dispatch(todoActions.toggleTodo(todo.id))}
/>
<span>{todo.text}</span>
<button onClick={() => dispatch(todoActions.deleteTodo(todo.id))}>
Delete
</button>
</li>
);
}
export default TodoItem;
// src/components/TodoFilters.tsx
import { useTodos, todoActions } from '../contexts/TodoContext';
function TodoFilters() {
const { state, dispatch } = useTodos();
const filters: Array<'all' | 'active' | 'completed'> = [
'all',
'active',
'completed'
];
return (
<div className="filters">
{filters.map(filter => (
<button
key={filter}
className={state.filter === filter ? 'active' : ''}
onClick={() => dispatch(todoActions.setFilter(filter))}
>
{filter.charAt(0).toUpperCase() + filter.slice(1)}
</button>
))}
</div>
);
}
export default TodoFilters;
Step 6: Put It All Together
// src/App.tsx
import { TodoProvider } from './contexts/TodoContext';
import AddTodo from './components/AddTodo';
import TodoList from './components/TodoList';
import TodoFilters from './components/TodoFilters';
function App() {
return (
<TodoProvider>
<div className="app">
<h1>π Todo App</h1>
<AddTodo />
<TodoFilters />
<TodoList />
</div>
</TodoProvider>
);
}
export default App;
β What We Achieved
- Global State - All components access the same todo state
- Predictable Updates - Reducer ensures consistent state changes
- Type Safety - TypeScript prevents action type errors
- Clean Components - Components focus on UI, not logic
- No Prop Drilling - Deep components access state directly
- Testability - Reducer can be tested independently
- Scalability - Easy to add new actions and features
Optimized Context Pattern
For better performance, split state and dispatch into separate contexts:
// Advanced pattern - separate contexts for state and dispatch
const TodoStateContext = createContext<TodoState | undefined>(undefined);
const TodoDispatchContext = createContext<React.Dispatch<TodoAction> | undefined>(undefined);
export function TodoProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(todoReducer, initialState);
return (
<TodoStateContext.Provider value={state}>
<TodoDispatchContext.Provider value={dispatch}>
{children}
</TodoDispatchContext.Provider>
</TodoStateContext.Provider>
);
}
// Separate hooks
export function useTodoState() {
const context = useContext(TodoStateContext);
if (!context) throw new Error('useTodoState must be used within TodoProvider');
return context;
}
export function useTodoDispatch() {
const context = useContext(TodoDispatchContext);
if (!context) throw new Error('useTodoDispatch must be used within TodoProvider');
return context;
}
// Now components only re-render when the value they use changes!
// Component that only dispatches doesn't re-render when state changes
function AddTodo() {
const dispatch = useTodoDispatch(); // Won't re-render on state change
// ...
}
// Component that only reads state doesn't need dispatch
function TodoStats() {
const state = useTodoState(); // Clean and efficient
// ...
}
π‘ Performance Benefit
Splitting contexts means components only re-render when the specific value they use changes. A component that only dispatches actions won't re-render when state changes, and vice versa. This is especially important for large applications!
ποΈ Hands-on Practice
Now it's your turn! These exercises will help you master Context and useContext. Start with the easier ones and work your way up.
Exercise 1: Language Context
π― Challenge
Create a language/internationalization (i18n) context that allows users to switch between languages.
Requirements:
- Support at least 2 languages (e.g., English and Spanish)
- Provide a way to switch languages
- Store translations in objects
- Create a custom hook to access translations
- Persist language preference to localStorage
π‘ Hint
Your context might look like:
interface LanguageContextType {
language: 'en' | 'es';
setLanguage: (lang: 'en' | 'es') => void;
t: (key: string) => string; // translation function
}
Translation object example:
const translations = {
en: {
welcome: 'Welcome',
greeting: 'Hello, {name}!'
},
es: {
welcome: 'Bienvenido',
greeting: 'Β‘Hola, {name}!'
}
};
β Solution
// LanguageContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
type Language = 'en' | 'es';
const translations = {
en: {
welcome: 'Welcome',
login: 'Login',
logout: 'Logout',
settings: 'Settings'
},
es: {
welcome: 'Bienvenido',
login: 'Iniciar sesiΓ³n',
logout: 'Cerrar sesiΓ³n',
settings: 'ConfiguraciΓ³n'
}
};
interface LanguageContextType {
language: Language;
setLanguage: (lang: Language) => void;
t: (key: string) => string;
}
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
export function LanguageProvider({ children }: { children: ReactNode }) {
const [language, setLanguage] = useState<Language>(() => {
const saved = localStorage.getItem('language');
return (saved as Language) || 'en';
});
const handleSetLanguage = (lang: Language) => {
setLanguage(lang);
localStorage.setItem('language', lang);
};
const t = (key: string): string => {
return translations[language][key as keyof typeof translations.en] || key;
};
return (
<LanguageContext.Provider value={{ language, setLanguage: handleSetLanguage, t }}>
{children}
</LanguageContext.Provider>
);
}
export function useLanguage() {
const context = useContext(LanguageContext);
if (!context) throw new Error('useLanguage must be used within LanguageProvider');
return context;
}
Exercise 2: Shopping Cart Context
π― Challenge
Build a shopping cart context using Context + useReducer.
Requirements:
- Add items to cart (with quantity)
- Remove items from cart
- Update item quantities
- Calculate total price
- Clear entire cart
- Use TypeScript for type safety
π‘ Hint
Types you'll need:
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
type CartAction =
| { type: 'ADD_ITEM'; payload: CartItem }
| { type: 'REMOVE_ITEM'; payload: { id: string } }
| { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } }
| { type: 'CLEAR_CART' };
Exercise 3: Authentication Context
π― Challenge
Create an authentication context with login, logout, and protected route logic.
Requirements:
- Login/logout functionality
- Store user information
- Check authentication status
- Persist auth state (localStorage or sessionStorage)
- Provide loading state during authentication
- Include user roles/permissions
π‘ Hint
Context structure:
interface AuthContextType {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
checkAuth: () => Promise<void>;
}
β Practice Tips
- Start with type definitions first
- Build the context and provider before the hook
- Test with console.log before building UI
- Add one feature at a time
- Use TypeScript errors as guides
- Don't forget error boundaries!
β‘ Performance Considerations
Context is powerful, but it can cause performance issues if not used carefully. Let's learn how to optimize Context usage.
The Re-render Problem
When Context value changes, ALL components that consume it will re-render:
// β Problem: Creates new object every render
function App() {
const [user, setUser] = useState(null);
return (
<UserContext.Provider value={{ user, setUser }}>
<Dashboard />
</UserContext.Provider>
);
}
// Every render of App creates a new object
// Even if user hasn't changed, ALL consumers re-render!
β οΈ Why This Happens
JavaScript creates a new object on each render:
{ user, setUser } !== { user, setUser } // Always true!
// React sees a different object, assumes value changed
Solution 1: Memoize the Value
import { useMemo } from 'react';
function App() {
const [user, setUser] = useState(null);
// β
Value only changes when user changes
const value = useMemo(
() => ({ user, setUser }),
[user] // Only recreate if user changes
);
return (
<UserContext.Provider value={value}>
<Dashboard />
</UserContext.Provider>
);
}
Solution 2: Split Contexts
Separate state and dispatch to minimize re-renders:
// Two separate contexts
const UserStateContext = createContext(null);
const UserDispatchContext = createContext(null);
function UserProvider({ children }) {
const [user, setUser] = useState(null);
// dispatch is stable, never changes
return (
<UserStateContext.Provider value={user}>
<UserDispatchContext.Provider value={setUser}>
{children}
</UserDispatchContext.Provider>
</UserStateContext.Provider>
);
}
// Components that only dispatch don't re-render on state changes!
function UpdateButton() {
const setUser = useContext(UserDispatchContext);
// Won't re-render when user state changes
return <button onClick={() => setUser(...)}>Update</button>;
}
Solution 3: Optimize with React.memo
import { memo } from 'react';
// Expensive component that uses context
const ExpensiveComponent = memo(function ExpensiveComponent() {
const { someValue } = useMyContext();
// Complex rendering logic
return <div>{someValue}</div>;
});
// Now it only re-renders when props or context actually change
Solution 4: Limit Provider Scope
// β Too broad - entire app re-renders
function App() {
return (
<ThemeProvider>
<Dashboard />
<Settings />
<Profile />
</ThemeProvider>
);
}
// β
Scoped - only affected components
function App() {
return (
<div>
<Dashboard /> {/* Doesn't use theme */}
<ThemeProvider>
<Settings /> {/* Uses theme */}
<Profile /> {/* Uses theme */}
</ThemeProvider>
</div>
);
}
Performance Best Practices
β Do's
- Use useMemo to stabilize context values
- Split contexts when state and actions are independent
- Wrap expensive components in React.memo
- Keep Provider scope as narrow as possible
- Use multiple small contexts instead of one large one
- Derive computed values in components, not context
β οΈ Don'ts
- Don't create new objects/arrays in Provider value
- Don't put everything in one giant context
- Don't wrap entire app if only few components need it
- Don't use Context for frequently changing values
- Don't optimize prematurely - measure first!
When to Optimize
| Scenario | Action |
|---|---|
| Small app (<50 components) | Don't worry about it |
| Context changes rarely | No optimization needed |
| Few consumers | Basic setup is fine |
| Large app (100+ components) | Consider splitting contexts |
| Frequent context updates | Use useMemo, split contexts |
| Performance issues detected | Profile and optimize |
β Best Practices
Follow these best practices to write maintainable, scalable Context code.
β Do: Create Custom Hooks
// β
Always provide a custom hook
export function useUser() {
const context = useContext(UserContext);
if (!context) {
throw new Error('useUser must be used within UserProvider');
}
return context;
}
// Usage is clean and safe
function MyComponent() {
const { user } = useUser(); // Clean!
}
β Do: Organize Context Files
// src/contexts/UserContext.tsx
// Everything in one file:
// - Types
// - Context creation
// - Provider component
// - Custom hook
// - Export only what's needed
export { UserProvider, useUser };
// Don't export the context itself
β Do: Use TypeScript
// β
Fully typed context
interface UserContextType {
user: User | null;
login: (credentials: LoginCredentials) => Promise<void>;
logout: () => void;
}
const UserContext = createContext<UserContextType | undefined>(undefined);
β Do: Split Large Contexts
// β One massive context
interface AppContextType {
user: User;
theme: Theme;
language: Language;
cart: Cart;
notifications: Notification[];
// ... too much!
}
// β
Multiple focused contexts
// UserContext, ThemeContext, LanguageContext, CartContext, etc.
// Each handles one concern
β Do: Provide Meaningful Errors
export function useUser() {
const context = useContext(UserContext);
if (!context) {
throw new Error(
'useUser must be used within UserProvider. ' +
'Make sure your component is wrapped with <UserProvider>.'
);
}
return context;
}
β Don't: Use Context for Everything
// β Overkill for parent-child communication
function Parent() {
return (
<ValueContext.Provider value="hello">
<Child />
</ValueContext.Provider>
);
}
// β
Just use props!
function Parent() {
return <Child value="hello" />;
}
β Don't: Mutate Context Values
// β Never mutate context values
const { user } = useUser();
user.name = 'New Name'; // NO!
// β
Use provided functions
const { user, updateUser } = useUser();
updateUser({ name: 'New Name' }); // YES!
Context Naming Conventions
| Item | Convention | Example |
|---|---|---|
| Context | PascalCase + Context | UserContext |
| Provider | PascalCase + Provider | UserProvider |
| Custom Hook | use + PascalCase | useUser |
| Type Interface | PascalCase + ContextType | UserContextType |
Testing Context
// Testing components that use context
import { render, screen } from '@testing-library/react';
import { UserProvider } from './UserContext';
import MyComponent from './MyComponent';
test('renders with user context', () => {
render(
<UserProvider>
<MyComponent />
</UserProvider>
);
expect(screen.getByText(/welcome/i)).toBeInTheDocument();
});
// Test with custom context value
test('renders with specific user', () => {
const mockUser = { id: 1, name: 'Test User' };
render(
<UserContext.Provider value={{ user: mockUser, login: jest.fn(), logout: jest.fn() }}>
<MyComponent />
</UserContext.Provider>
);
expect(screen.getByText('Test User')).toBeInTheDocument();
});
π Summary
Congratulations! You've mastered the useContext Hook and the Context API. Let's recap what you've learned.
π― Key Takeaways
- Context solves prop drilling - Share data without passing props through every level
- Three-part system - Create Context, Provide values, Consume with useContext
- Custom hooks are essential - Always create custom hooks for type safety and error checking
- TypeScript makes it better - Type safety prevents bugs and improves DX
- Context + useReducer - Powerful pattern for complex global state
- Performance matters - Use useMemo, split contexts, optimize strategically
- Don't overuse - Props are fine for parent-child communication
What You Learned
| Concept | What You Can Do |
|---|---|
| Context Creation | Create typed contexts with TypeScript |
| Providers | Build custom Provider components with state |
| useContext | Consume context values with the hook |
| Custom Hooks | Create safe, reusable context hooks |
| Real-World Patterns | Build theme switchers, auth, todos |
| Performance | Optimize context for large applications |
Common Use Cases for Context
- π¨ Theme - Dark/light mode, color schemes
- π Authentication - Current user, login status
- π Language - Internationalization (i18n)
- π Shopping Cart - Cart items, totals
- βοΈ Settings - User preferences
- π Notifications - Toast messages, alerts
- π± Responsive - Screen size, device type
- π― Feature Flags - Enable/disable features
Context vs Other Solutions
| Solution | When to Use |
|---|---|
| Props | Parent β immediate child communication |
| Context | Share data across many components at different nesting levels |
| Redux/Zustand | Very large apps, time-travel debugging, complex middleware needs |
| React Query | Server state management, caching, synchronization |
Next Steps
π Continue Learning
Now that you know useContext, you're ready for:
- Lesson 5.3: useRef - Access DOM elements and store mutable values
- Lesson 5.4: useMemo & useCallback - Performance optimization
- Lesson 5.5: Compound Components - Advanced patterns
- Module Project - Build a complete app with Context
Practice Projects
β Build These to Master Context
- Multi-theme App - 3+ themes with custom colors
- E-commerce with Cart - Products, cart, checkout
- Dashboard with Auth - Login, protected routes, user management
- Kanban Board - Drag and drop with global state
- Multi-language Blog - i18n with language switching
π Congratulations! π
You've completed the useContext lesson and gained a powerful tool for managing shared state. You can now build scalable React applications with clean, maintainable code!
Keep practicing, keep building, and keep learning! π