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 CounterState interface ensures type safety for state and actions.
  • Provider: Wraps your app or a subtree to provide state.
  • Custom Hook: useCounter simplifies 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 useState are client-side in Next.js.
  • Place the provider in layout.tsx for 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 useCounter hook provides type-safe access to the state and actions.

3. Key Concepts

  • Context Object: Created with createContext, holds the state and methods.
  • Provider: The Provider component supplies the state to all descendants.
  • Consumer: Access via useContext (preferred) or the Consumer component for older patterns.
  • State Management: Typically paired with useState or useReducer for 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 getServerSideProps or 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

  1. Ease of Use
    • Context: More setup—create context, provider, and hook, then wrap components. Error-prone if not scoped correctly.
    • Zustand: Simpler, with one create call and no provider. Actions and state are defined together, reducing boilerplate.
  2. Performance
    • Context: When the provider’s value changes, all consuming components re-render unless you use React.memo or split contexts. For example, if count and user are in one context, updating count re-renders components using only user.
    • Zustand: Selectors (e.g., useStore((state) => state.count)) ensure only components using the changed state re-render, making it more efficient for dynamic state.
  3. 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: create infers types from the store definition, and middleware integrates cleanly with TypeScript, offering a smoother experience.
  4. 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., persist for localStorage) make it versatile for dynamic Next.js apps. SSR needs similar checks.
  5. Flexibility
    • Context: Flexible with useReducer for complex logic, but persistence, logging, etc., require custom code.
    • Zustand: Middleware like persist, devtools, and immer (for immutable updates) make it more powerful out of the box.
  6. 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