React Context is a built-in React API for managing global state without passing props through every level of the component tree. It’s ideal for sharing data (e.g., themes, user data, settings) across components in a Next.js app, and it works well with TypeScript for type-safe state management.
2. Setup in Next.js with TypeScript
Here’s how to implement Context in your project:
a. Create a Context
Define the context and its types in a separate file (e.g., context/CounterContext.ts):
import { createContext, useContext, ReactNode } from 'react';
// Define the shape of the state and actions with TypeScript
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
// Initial state
const initialState: CounterState = {
count: 0,
increment: () => {},
decrement: () => {},
reset: () => {},
};
// Create the context
const CounterContext = createContext<CounterState>(initialState);
// Provider component to manage state
export const CounterProvider = ({ children }: { children: ReactNode }) => {
const [count, setCount] = useState<number>(0);
const increment = () => setCount((prev) => prev + 1);
const decrement = () => setCount((prev) => prev - 1);
const reset = () => setCount(0);
const value = { count, increment, decrement, reset };
return (
<CounterContext.Provider value={value}>
{children}
</CounterContext.Provider>
);
};
// Custom hook for consuming the context
export const useCounter = () => {
const context = useContext(CounterContext);
if (!context) {
throw new Error('useCounter must be used within a CounterProvider');
}
return context;
};
- TypeScript: The
CounterStateinterface ensures type safety for state and actions. - Provider: Wraps your app or a subtree to provide state.
- Custom Hook:
useCountersimplifies access and adds a safety check for usage outside the provider.
b. Wrap Your App
In a Next.js project, wrap your components with the provider. For the App Router (Next.js 13+), do this in a layout or client component:
// app/layout.tsx
'use client'; // Mark as client component for hooks
import { CounterProvider } from '../context/CounterContext';
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<CounterProvider>
{children}
</CounterProvider>
</body>
</html>
);
}
- ‘use client’: Required because Context and hooks like
useStateare client-side in Next.js. - Place the provider in
layout.tsxfor app-wide access or in a specific page/component for scoped access.
c. Consume the Context
Use the custom hook in a component:
// app/components/Counter.tsx
'use client';
import { useCounter } from '../context/CounterContext';
export default function Counter() {
const { count, increment, decrement, reset } = useCounter();
return (
<div>
<h1>Count: {count}</h1>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
<button onClick={reset}>Reset</button>
</div>
);
}
- The
useCounterhook provides type-safe access to the state and actions.
3. Key Concepts
- Context Object: Created with
createContext, holds the state and methods. - Provider: The
Providercomponent supplies the state to all descendants. - Consumer: Access via
useContext(preferred) or theConsumercomponent for older patterns. - State Management: Typically paired with
useStateoruseReducerfor updates. - TypeScript: Define an interface for the context value to ensure type safety.
4. Advanced Usage
-
With useReducer: For complex state logic, combine with
useReducer:interface State { count: number; } type Action = { type: 'increment' } | { type: 'decrement' } | { type: 'reset' }; const reducer = (state: State, action: Action): State => { switch (action.type) { case 'increment': return { count: state.count + 1 }; case 'decrement': return { count: state.count - 1 }; case 'reset': return { count: 0 }; default: return state; } }; const CounterContext = createContext<{ state: State; dispatch: React.Dispatch<Action>; }>({ state: { count: 0 }, dispatch: () => {} }); export const CounterProvider = ({ children }: { children: ReactNode }) => { const [state, dispatch] = useReducer(reducer, { count: 0 }); return ( <CounterContext.Provider value=> {children} </CounterContext.Provider> ); }; export const useCounter = () => { const context = useContext(CounterContext); if (!context) throw new Error('useCounter must be used within a CounterProvider'); return context; };Usage:
const { state, dispatch } = useCounter(); dispatch({ type: 'increment' });. -
SSR Considerations: Context is client-side, so wrap it in a client component. For server-side data, fetch in
getServerSidePropsor API routes and pass initial values to the provider.
5. Best Practices
- Scope Providers: Place providers only around components that need the data to avoid unnecessary re-renders.
- Avoid Overuse: Context is best for global, infrequently updated state (e.g., themes, user auth). For frequent updates, performance can suffer.
- Type Safety: Always define types/interfaces for the context value.
- Error Handling: Include a check in your custom hook to prevent misuse outside the provider.
- Next.js: Use
'use client'for components with Context, as server components can’t use hooks.
6. Installation
No installation is needed—Context is built into React, included with Next.js. Your PNPM setup requires no additional dependencies.
Detailed Comparison: React Context vs. Zustand
| Aspect | React Context | Zustand |
|---|---|---|
| Setup | Requires creating a context, provider, and custom hook. Must wrap app or subtree in a provider. | Single create function to define a store. No provider needed—globally accessible. |
| Boilerplate | More verbose: context, provider, and consumer/hook setup. | Minimal: one create call defines state and actions. |
| TypeScript Support | Strong, but requires manual type definitions for context value and careful hook setup. | Excellent, with type inference via create and clean integration of interfaces. |
| Performance | Re-renders all consumers when context value changes, unless optimized with memoization or split contexts. | Optimized with selectors—only components using changed state re-render. |
| State Management | Relies on useState or useReducer. Updates are manual and can get complex. |
Built-in set function for simple, immutable updates. Handles complex logic well. |
| Next.js Compatibility | Works in client components ('use client'). SSR requires careful initial state handling. |
Works in client components. Middleware (e.g., persist) needs SSR checks (e.g., typeof window). |
| Scalability | Good for small to medium apps or static data (e.g., themes). Large apps need multiple contexts to avoid re-renders. | Scales well for small to complex apps, with middleware for persistence, devtools, etc. |
| Async Actions | Handled manually with async functions in provider or via useReducer. |
Naturally supports async actions in the store, cleaner and centralized. |
| Middleware | No built-in middleware. Persistence or logging requires custom solutions. | Rich middleware: persist, devtools, immer for immutability, etc. |
| Bundle Size | Built into React, no extra size. | ~1KB, lightweight and negligible impact. |
| Debugging | No built-in tools. Use React DevTools to inspect context. | Middleware for Redux DevTools, logging, and more for easier debugging. |
| Use Case | Best for static or rarely updated global state (e.g., user auth, theme). | Ideal for dynamic, complex, or frequently updated state in Next.js apps. |
| Learning Curve | Moderate: Requires understanding providers, consumers, and re-render behavior. | Simple: Minimal API, intuitive for React developers. |
| SSR in Next.js | State is client-side; initialize via props from server (e.g., getServerSideProps). |
State is client-side; persist middleware needs SSR guards for storage. |
Detailed Analysis
- Ease of Use
- Context: More setup—create context, provider, and hook, then wrap components. Error-prone if not scoped correctly.
- Zustand: Simpler, with one
createcall and no provider. Actions and state are defined together, reducing boilerplate.
- Performance
- Context: When the provider’s value changes, all consuming components re-render unless you use
React.memoor split contexts. For example, ifcountanduserare in one context, updatingcountre-renders components using onlyuser. - Zustand: Selectors (e.g.,
useStore((state) => state.count)) ensure only components using the changed state re-render, making it more efficient for dynamic state.
- Context: When the provider’s value changes, all consuming components re-render unless you use
- TypeScript
- Context: Type safety requires defining an interface and passing it to
createContext. Missteps (e.g., weak initial state) can lead to runtime errors. - Zustand:
createinfers types from the store definition, and middleware integrates cleanly with TypeScript, offering a smoother experience.
- Context: Type safety requires defining an interface and passing it to
- Next.js Fit
- Context: Works well for static global state (e.g., theme) in Next.js. Requires
'use client'for components and careful SSR handling for initial state. - Zustand: Also client-side, but its simplicity and middleware (e.g.,
persistfor localStorage) make it versatile for dynamic Next.js apps. SSR needs similar checks.
- Context: Works well for static global state (e.g., theme) in Next.js. Requires
- Flexibility
- Context: Flexible with
useReducerfor complex logic, but persistence, logging, etc., require custom code. - Zustand: Middleware like
persist,devtools, andimmer(for immutable updates) make it more powerful out of the box.
- Context: Flexible with
- When to Use
- Context: Use for simple, static state (e.g., user session, theme) in a small Next.js app. Avoid for frequent updates or complex logic due to re-render risks.
- Zustand: Use for dynamic, complex, or frequently updated state (e.g., counters, forms, API-driven data). Scales better and simplifies management.
Example: Same Feature in Both
Context (Counter):
// context/CounterContext.ts
import { createContext, useContext, ReactNode, useState } from 'react';
interface CounterState {
count: number;
increment: () => void;
}
const CounterContext = createContext<CounterState | null>(null);
export const CounterProvider = ({ children }: { children: ReactNode }) => {
const [count, setCount] = useState(0);
const increment = () => setCount((prev) => prev + 1);
return <CounterContext.Provider value=8>{children}</CounterContext.Provider>;
};
export const useCounter = () => {
const context = useContext(CounterContext);
if (!context) throw new Error('useCounter must be used within a CounterProvider');
return context;
};
// app/components/Counter.tsx
'use client';
import { useCounter } from '../context/CounterContext';
export default function Counter() {
const { count, increment } = useCounter();
return <div>{count} <button onClick={increment}>Increment</button></div>;
}
Zustand (Counter):
// store/counterStore.ts
import { create } from 'zustand';
interface CounterState {
count: number;
increment: () => void;
}
const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
// app/components/Counter.tsx
'use client';
import { useCounterStore } from '../store/counterStore';
export default function Counter() {
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
return <div>{count} <button onClick={increment}>Increment</button></div>;
}
Observation: Context requires a provider and more setup, while Zustand is concise and provider-free.
Recommendation for Your Project
- Use Context if your Next.js app needs simple, static global state (e.g., theme, user auth) and you want to avoid extra dependencies.
- Use Zustand if your app has dynamic, complex, or frequently updated state (e.g., forms, counters, API data), as it’s more efficient, scalable, and TypeScript-friendly.
- For a Next.js, TypeScript, and PNPM project, Zustand is often better due to its simplicity, performance, and middleware, especially for interactive apps.
Comments