React Context with TypeScript: A Complete Guide
Step 1: Understanding the Basics
Context solves the "prop drilling" problem. Instead of passing data through multiple components, you create a context that any component can access directly.
Step 2: Creating a Simple Context
Let's start with a theme context example:
// src/contexts/ThemeContext.tsx
'use client'
import { createContext, useContext, useState, ReactNode } from 'react'
// 1. Define the shape of your context data
type Theme = 'light' | 'dark'
interface ThemeContextType {
theme: Theme
toggleTheme: () => void
}
// 2. Create the context with a default value (or undefined)
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
// 3. Create a provider component
interface ThemeProviderProps {
children: ReactNode
}
export function ThemeProvider({ children }: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>('light')
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light')
}
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
// 4. Create a custom hook for easy access
export function useTheme() {
const context = useContext(ThemeContext)
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider')
}
return context
}Step 3: Using the Context in Your App
In Next.js 16+ App Router, wrap your app with the provider:
// src/app/layout.tsx
import { ThemeProvider } from '@/contexts/ThemeContext'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
)
}Step 4: Consuming the Context
Now any component can access the theme:
// src/components/ThemeToggle.tsx
'use client'
import { useTheme } from '@/contexts/ThemeContext'
import { Button } from '@/components/ui/button'
import { Moon, Sun } from 'lucide-react'
export function ThemeToggle() {
const { theme, toggleTheme } = useTheme()
return (
<Button onClick={toggleTheme} variant="outline" size="icon">
{theme === 'light' ? <Moon className="h-5 w-5" /> : <Sun className="h-5 w-5" />}
</Button>
)
}Step 5: More Complex Example - User Authentication
Here's a more realistic example with authentication:
// src/contexts/AuthContext.tsx
'use client'
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
interface User {
id: string
name: string
email: string
}
interface AuthContextType {
user: User | null
isLoading: boolean
login: (email: string, password: string) => Promise<void>
logout: () => void
isAuthenticated: boolean
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
// Check if user is logged in on mount
checkAuth()
}, [])
const checkAuth = async () => {
try {
// Your auth check logic here
const response = await fetch('/api/auth/me')
if (response.ok) {
const userData = await response.json()
setUser(userData)
}
} catch (error) {
console.error('Auth check failed:', error)
} finally {
setIsLoading(false)
}
}
const login = async (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) {
const userData = await response.json()
setUser(userData)
} else {
throw new Error('Login failed')
}
}
const logout = () => {
setUser(null)
// Clear cookies, localStorage, etc.
}
return (
<AuthContext.Provider
value={{
user,
isLoading,
login,
logout,
isAuthenticated: !!user
}}
>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}Using it in a component:
// src/components/UserProfile.tsx
'use client'
import { useAuth } from '@/contexts/AuthContext'
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
export function UserProfile() {
const { user, logout, isAuthenticated } = useAuth()
if (!isAuthenticated) {
return <div>Please log in</div>
}
return (
<Card>
<CardHeader>
<CardTitle>Welcome, {user?.name}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{user?.email}</p>
<Button onClick={logout} className="mt-4" variant="destructive">
Logout
</Button>
</CardContent>
</Card>
)
}Step 6: Combining Multiple Contexts
You can nest providers or create a combined provider:
// src/app/layout.tsx
import { ThemeProvider } from '@/contexts/ThemeContext'
import { AuthProvider } from '@/contexts/AuthContext'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<AuthProvider>
<ThemeProvider>
{children}
</ThemeProvider>
</AuthProvider>
</body>
</html>
)
}Best Practices
Use custom hooks: Always create a custom hook (like
useTheme) to access your context. This provides better error messages and TypeScript support.Split contexts: Don't put everything in one giant context. Create separate contexts for different concerns (auth, theme, settings, etc).
Avoid unnecessary re-renders: Components that use context will re-render when the context value changes. To optimize, you can split contexts or use
useMemofor the context value.TypeScript safety: Always type your context properly. Using
undefinedas the initial value and checking in the custom hook ensures type safety.Mark as 'use client': In Next.js App Router, context providers must be client components, so always add
'use client'at the top of your context files.