Tech Verse Logo
Enable dark mode
React Context: When to Reach for a Store

React Context: When to Reach for a Store

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

4 min read

The Re-Render Trap in React 19

Stop treating React Context as a drop-in replacement for Redux or Zustand. I've audited dozens of Next.js 16 codebases where team members placed their entire application state into a single global provider and then wondered why typing into a text field dropped frame rates down to 14 FPS.

React Context is a dependency injection mechanism, not a state manager. It transports values down the component tree without prop drilling. But when the value provided by the Context updates, React 19 marks every single consumer using use(Context) or useContext as dirty. If your context holds an object with ten properties, a component reading just one property still re-renders when the other nine update.

Here is the typical broken pattern found in production repositories:

// Bad: Combined context holding dynamic state and static handlers
import { createContext, useState, useContext, ReactNode } from 'react';

type AppState = {
  user: { name: string; email: string } | null;
  theme: 'light' | 'dark';
  sidebarOpen: boolean;
  setSidebarOpen: (open: boolean) => void;
};

const AppContext = createContext<AppState | null>(null);

export function AppProvider({ children }: { children: ReactNode }) {
  const [user] = useState(null);
  const [theme] = useState<'light' | 'dark'>('light');
  const [sidebarOpen, setSidebarOpen] = useState(false);

  return (
    <AppContext.Provider value={{ user, theme, sidebarOpen, setSidebarOpen }}>
      {children}
    </AppContext.Provider>
  );
}

When a user toggles the sidebar, setSidebarOpen triggers a state change inside AppProvider. Because a new object literal is passed to value, every single component consuming AppContext executes its render body again. In a tiny application, you won't notice. In a dashboard rendering 2,000 DOM nodes across data tables, your interaction latency spikes from 12ms to 240ms.

Provider Splitting: The 80% Solution

Before adding zustand to your package.json, split your providers by domain and separate state values from state update functions. Function references created with useCallback or setter functions from useState remain stable across renders. When you separate read context from write context, components that only trigger actions never re-render when state values change.

Here is how you write optimized native providers in React 19 without extra third-party dependencies:

// Good: Split state and dispatch into isolated contexts
import { createContext, useState, useMemo, useCallback, ReactNode } from 'react';

type SidebarState = boolean;
type SidebarDispatch = {
  toggleSidebar: () => void;
};

const SidebarStateContext = createContext<SidebarState | null>(null);
const SidebarDispatchContext = createContext<SidebarDispatch | null>(null);

export function SidebarProvider({ children }: { children: ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);

  const toggleSidebar = useCallback(() => {
    setIsOpen((prev) => !prev);
  }, []);

  const dispatchValue = useMemo(() => ({ toggleSidebar }), [toggleSidebar]);

  return (
    <SidebarDispatchContext.Provider value={dispatchValue}>
      <SidebarStateContext.Provider value={isOpen}>
        {children}
      </SidebarStateContext.Provider>
    </SidebarDispatchContext.Provider>
  );
}

This pattern completely isolates component render trees. A toggle button sitting inside your top navigation bar consumes SidebarDispatchContext. When clicked, isOpen updates, triggering a re-render only for elements reading SidebarStateContext. The toggle button itself does not re-render.

When You Really Need an External Store

Provider splitting handles settings, auth payloads, and basic UI state cleanly. However, Context hits a wall when handling fast-changing data feeds or complex sub-tree state queries.

High-Frequency State Updates

If you build a real-time monitor fed by WebSockets, a interactive graphics layout tool, or tabular data requiring live recalculations, standard Context is the wrong tool. When events arrive every 16 milliseconds to hit 60 FPS, React's context tree evaluation overhead drops frame rendering rates. External state stores like Zustand 5 or Jotai bypass React's render tree propagation completely. They store state outside React and force updates strictly on components subscribing to specific slices via selector functions.

Transient State vs Domain Cache

Never put transient state—like text input changes before submitting a form—into global React Context. Keep form state local using component state or dedicated form handling utilities. Similarly, server data fetched from a Laravel 12 API backend using PHP 8.3 should live inside TanStack Query or SWR rather than custom Context implementations. Context is not a caching layer; don't make it handle network retries, cache tags, and revalidation logic.

Next.js 16 and Server Component Boundaries

In Next.js 16 using React 19, Server Components represent the default execution environment. Context providers require the 'use client' directive. Wrapping your root layout in a massive client component provider strips away the rendering benefits of Server Components for lower sub-trees.

Pass initial server-side state from your Next.js server component directly into dedicated client providers at low sub-tree boundaries:

// app/dashboard/page.tsx (Server Component)
import { UserProvider } from '@/components/UserProvider';
import { DashboardClient } from '@/components/DashboardClient';

async function getProfile() {
  const res = await fetch('https://api.example.com/api/v1/user', {
    headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
    cache: 'no-store',
  });
  return res.json();
}

export default async function DashboardPage() {
  const userData = await getProfile();

  return (
    <UserProvider initialUser={userData}>
      <DashboardClient />
    </UserProvider>
  );
}

This architecture keeps initial data hydration fast on the server while granting client components clean prop-free access to static user configuration without duplicating backend HTTP calls.

The Practical Rule of Thumb

Do not pull external state management libraries into your dependencies out of reflex. Start with localized state. If passing props down exceeds three levels, colocate your state or split your Context into distinct state and dispatch providers. When your application requires fine-grained value selectors, atomic updates faster than 16ms, or offline storage sync, reach for an external store like Zustand.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    React Context: When to Reach for a Store

    React Context: When to Reach for a Store

    Custom React Hooks: Abstraction vs Indirection

    Custom React Hooks: Abstraction vs Indirection

    Stop Wasting React Performance on memo and useMemo

    Stop Wasting React Performance on memo and useMemo

    Next.js Partial Prerendering: Static Shells, Dynamic Holes

    Next.js Partial Prerendering: Static Shells, Dynamic Holes

    Stop Re-rendering: When to Drop useEffect in React 19

    Stop Re-rendering: When to Drop useEffect in React 19

    Fixing Next.js Hydration Mismatch Errors in React 19

    Fixing Next.js Hydration Mismatch Errors in React 19

    Generating Open Graph Images at the Edge in Next.js 16

    Generating Open Graph Images at the Edge in Next.js 16

    Fix Next.js Third Party Script Performance

    Fix Next.js Third Party Script Performance

    Next.js 16 Testing Strategy: Unit, Component, E2E

    Next.js 16 Testing Strategy: Unit, Component, E2E

    Shrinking Next.js Bundle Size: Analyzer & Barrel Fixes

    Shrinking Next.js Bundle Size: Analyzer & Barrel Fixes