Skip to main content

Next.js React Context

In modern web applications, managing state efficiently is critical for building scalable and maintainable frontends. Next.js, being built on top of React, offers full support for React's Context API, which provides a way to share data across your component tree without manually passing props down at every level.

Introduction to React Context in Next.js

React Context API solves the problem of "prop drilling" - the process of passing props through multiple levels of components just to get data to a deeply nested component. In Next.js applications, this becomes especially important as applications grow in complexity.

Context provides a way to share values between components without explicitly passing a prop through every level of the component tree.

When to Use Context in Next.js

Context is ideal for:

  • Theme data (dark mode vs. light mode)
  • User authentication state
  • Language preferences
  • UI state that affects multiple components

However, Context is not recommended for high-frequency updates, as it can lead to performance issues. For those cases, consider more specialized state management libraries like Redux or Zustand.

Basic Context Setup in Next.js

Let's create a simple theme context that allows components throughout your Next.js app to access and update theme settings:

Step 1: Create the Context

First, let's create our context in a separate file:

jsx
// contexts/ThemeContext.js
import { createContext, useState, useContext } from 'react';

const ThemeContext = createContext();

export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');

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

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

export function useTheme() {
return useContext(ThemeContext);
}

Step 2: Wrap Your App with the Provider

In Next.js, we wrap our application with the context provider in _app.js:

jsx
// pages/_app.js
import { ThemeProvider } from '../contexts/ThemeContext';
import '../styles/globals.css';

function MyApp({ Component, pageProps }) {
return (
<ThemeProvider>
<Component {...pageProps} />
</ThemeProvider>
);
}

export default MyApp;

Step 3: Use the Context in Components

Now any component in your application can access the theme context:

jsx
// components/ThemeSwitcher.js
import { useTheme } from '../contexts/ThemeContext';

export default function ThemeSwitcher() {
const { theme, toggleTheme } = useTheme();

return (
<button
onClick={toggleTheme}
style={{
backgroundColor: theme === 'light' ? '#333' : '#fff',
color: theme === 'light' ? '#fff' : '#333',
padding: '8px 16px',
borderRadius: '4px',
border: 'none',
cursor: 'pointer',
}}
>
Switch to {theme === 'light' ? 'dark' : 'light'} mode
</button>
);
}

Using Context with Server Components (Next.js 13+)

With Next.js 13's App Router and Server Components, using Context requires some adjustments. Context can only be used in Client Components, so you need to mark your context providers with the 'use client' directive:

jsx
// contexts/ThemeContext.js
'use client';

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

const ThemeContext = createContext();

// Rest of the context code as before

Then, in your layout or template file:

jsx
// app/layout.js
import { ThemeProvider } from '../contexts/ThemeContext';

export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
}

Creating a More Complex Context: User Authentication

Let's build a more practical example: a user authentication context that manages login state, user information, and authentication methods:

jsx
// contexts/AuthContext.js
'use client';

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

const AuthContext = createContext();

export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);

// Check if user is already logged in on mount
useEffect(() => {
// This would typically be an API call or check localStorage
const checkLoggedIn = async () => {
try {
// Example: Check localStorage or cookies for auth token
const token = localStorage.getItem('auth_token');

if (token) {
// Fetch user data with the token
const response = await fetch('/api/user', {
headers: {
'Authorization': `Bearer ${token}`
}
});

if (response.ok) {
const userData = await response.json();
setUser(userData);
} else {
// Invalid token
localStorage.removeItem('auth_token');
}
}
} catch (error) {
console.error('Authentication error:', error);
} finally {
setLoading(false);
}
};

checkLoggedIn();
}, []);

// Login function
const login = async (email, password) => {
setLoading(true);

try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});

if (response.ok) {
const { user, token } = await response.json();
localStorage.setItem('auth_token', token);
setUser(user);
return { success: true };
} else {
const error = await response.json();
return { success: false, error: error.message };
}
} catch (error) {
return { success: false, error: error.message };
} finally {
setLoading(false);
}
};

// Logout function
const logout = () => {
localStorage.removeItem('auth_token');
setUser(null);
};

return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
}

export function useAuth() {
return useContext(AuthContext);
}

Now we can implement a login component that uses this context:

jsx
// components/LoginForm.js
'use client';

import { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { useRouter } from 'next/navigation';

export default function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { login } = useAuth();
const router = useRouter();

const handleSubmit = async (e) => {
e.preventDefault();
setError('');

const result = await login(email, password);

if (result.success) {
router.push('/dashboard');
} else {
setError(result.error || 'Login failed');
}
};

return (
<form onSubmit={handleSubmit} className="login-form">
<h2>Login</h2>

{error && <div className="error">{error}</div>}

<div className="form-group">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>

<div className="form-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>

<button type="submit">Login</button>
</form>
);
}

And a component that displays user information:

jsx
// components/UserProfile.js
'use client';

import { useAuth } from '../contexts/AuthContext';

export default function UserProfile() {
const { user, loading, logout } = useAuth();

if (loading) {
return <div>Loading user data...</div>;
}

if (!user) {
return <div>Please log in to view your profile</div>;
}

return (
<div className="user-profile">
<h2>User Profile</h2>
<div className="user-info">
<p><strong>Name:</strong> {user.name}</p>
<p><strong>Email:</strong> {user.email}</p>
{/* Display other user information */}
</div>

<button onClick={logout} className="logout-button">
Logout
</button>
</div>
);
}

Best Practices for Context in Next.js

  1. Split Contexts by Domain: Create separate contexts for different domains (e.g., auth, theme, shopping cart) rather than one giant context.

  2. Memoize Values: Use useMemo for context values to prevent unnecessary re-renders:

jsx
const value = useMemo(() => ({
user,
loading,
login,
logout,
}), [user, loading]);

return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
  1. Combine with useReducer for Complex State: For complex state logic, combine Context with useReducer:
jsx
function cartReducer(state, action) {
switch(action.type) {
case 'ADD_ITEM':
return { ...state, items: [...state.items, action.payload] };
case 'REMOVE_ITEM':
return {
...state,
items: state.items.filter(item => item.id !== action.payload)
};
case 'CLEAR_CART':
return { ...state, items: [] };
default:
return state;
}
}

export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, { items: [] });

// Define convenience methods
const addItem = (item) => dispatch({ type: 'ADD_ITEM', payload: item });
const removeItem = (id) => dispatch({ type: 'REMOVE_ITEM', payload: id });
const clearCart = () => dispatch({ type: 'CLEAR_CART' });

return (
<CartContext.Provider value={{
items: state.items,
addItem,
removeItem,
clearCart
}}>
{children}
</CartContext.Provider>
);
}
  1. Performance Considerations: Context causes all components that use it to re-render when the context value changes. For better performance with frequently changing values, consider using state management libraries like Zustand or Jotai that implement selective re-rendering.

Migrating from global state to Context

If you're migrating from global state variables or other patterns, here's how to transition smoothly:

jsx
// Before - global state
let darkMode = false;

function toggleDarkMode() {
darkMode = !darkMode;
updateUI(); // Need to manually update UI
}

// After - React Context
export function ThemeProvider({ children }) {
const [darkMode, setDarkMode] = useState(false);

const toggleDarkMode = () => {
setDarkMode(!darkMode);
// UI updates automatically!
};

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

Summary

React Context is a powerful tool in Next.js applications for sharing state across components without prop drilling. It's excellent for:

  • Global UI state like themes and language preferences
  • User authentication state
  • Application-wide settings

For the best experience:

  • Split contexts by domain/purpose
  • Use useMemo to optimize performance
  • Combine with useReducer for complex state logic
  • Consider specialized state management libraries for high-frequency updates

By implementing React Context effectively in your Next.js applications, you'll create cleaner, more maintainable code with improved developer experience.

Additional Resources

Exercises

  1. Build a language context to implement a multilingual Next.js site with at least two languages.
  2. Create a shopping cart context that persists cart items in localStorage.
  3. Implement a notification context that shows toast messages to users for various application events.
  4. Build a form context that manages form state and validation across multiple form steps in a wizard interface.
  5. Combine useContext with useReducer to create a todo list application with filtering capabilities.


If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)