ProgrUmar Logo
Module 8: State Management & Client Patterns

When (and When Not) to Use Client State

Duration: 12 mins

Server-first State

The App Router means a lot of what used to live in global client state — fetched data, user info, preferences — can now live on the server or in the URL. Only reach for client state for genuinely ephemeral UI state.

When (and When Not) to Use Client State

In traditional Single Page Applications (SPAs like raw React with Create React App), client-side state management libraries (such as Redux, MobX, or global Zustand stores) served as the primary database cache. The client was responsible for fetching data, maintaining pagination indices, caching records, and managing loading states. In the Next.js App Router, the server is the primary engine. React Server Components and server-side request caches eliminate the need for global client stores in most scenarios. In this lesson, we will analyze this state paradigm shift, identify when client stores are genuinely necessary, and map out the four locations where your application state should live.


1. The Client State Paradigm Shift in Next.js

In Next.js, data is fetched and cached directly on the server. Because Server Components can render dynamic data straight into the HTML payload, you no longer need client-side stores to cache query responses.

What we no longer keep in client stores:

  • Fetched list data (e.g. state.users = users).
  • Sorting, filter, and page offsets (use the URL instead).
  • Detailed user authentication parameters (use Server session caches instead).

2. The Risk of Node.js Module Singletons

In client-only applications, declaring a global state instance outside of React components is safe because the JS bundle runs entirely inside a single user's browser.

In Next.js, this is highly dangerous:

// DANGEROUS: DO NOT DO THIS
// This store instance is shared across all concurrent HTTP requests in Node.js memory!
import { createStore } from 'some-store-library';

export const globalStore = createStore({
  userId: null, // User A's ID can leak to User B!
});

If you define global state variables outside of React's render tree on the server, that state persists in Node.js memory across separate HTTP requests, leaking user profiles to concurrent visitors.


3. Where Should Your State Live?

Choose from the four primary state targets based on persistence and performance needs:

State Location Ideal For Primary API
Database / Server Cache Persistent domain records, course progress, user profiles. Server Components, fetch revalidations, Prisma/Drizzle queries.
URL Query Parameters Pagination page offsets, search terms, table filters. useSearchParams, nuqs.
Cookies Session tokens, language localizations, persistent dark mode preferences. cookies() header reader, cookie jars.
React Client Memory Ephemeral UI toggles, active modals, form entry states, drag-and-drop actions. useState, useReducer, React Context, local Zustand.

4. When is Global Client State Genuinely Required?

While server-first architectures handle data, client-side memory stores are still necessary for specific interactions:

  • Complex Interactive Canvas/UI: Building a drag-and-drop interface, interactive graphs, or checkout flows where state changes on every mouse movement without triggering database queries.
  • Real-time WebSocket Feeds: Storing incoming chat messages, active typing statuses, or stock market ticker arrays in client memory.
  • Optimistic UI Updates: Updating visual components instantly while network actions resolve in the background.
  • Offline/Local Cache support: Persisting draft forms inside indexedDB or localStorage.

5. Comparing Server Data Passing vs. Client Hook Stores

Instead of importing stores globally, pass data down from Server Components as React props, or isolate client stores using component-level React Context:

// app/courses/page.tsx (Server Component)
import { CourseList } from '@/components/CourseList';

async function fetchCourses() {
  const res = await fetch('https://api.progrumar.com/courses');
  return res.json();
}

export default async function CoursesPage() {
  const courses = await fetchCourses();
  
  // Pass server data directly as props
  return <CourseList initialCourses={courses} />;
}

Now, inside the Client Component, initialize local state if mutations are needed:

// components/CourseList.tsx
'use client';

import { useState } from 'react';

export function CourseList({ initialCourses }: { initialCourses: any[] }) {
  const [courses, setCourses] = useState(initialCourses);
  const [searchTerm, setSearchTerm] = useState('');

  const filtered = courses.filter(c => c.title.toLowerCase().includes(searchTerm.toLowerCase()));

  return (
    <div>
      <input value={searchTerm} onChange={e => setSearchTerm(e.target.value)} />
      <ul>
        {filtered.map(c => <li key={c.id}>{c.title}</li>)}
      </ul>
    </div>
  );
}

6. The Danger of Hydration Mismatches

When Next.js pre-renders a Client Component on the server, it expects the initial server HTML to match the initial client-side render.

If your Client Component attempts to read variables that only exist in the browser (like window.innerWidth, localStorage, or user cookies) to toggle rendering states, it throws a hydration mismatch error:

// Bad: Server does not have localStorage, causing hydration errors
'use client';
export function UserTheme() {
  const theme = localStorage.getItem('theme'); // Will fail on server
  return <div>Active: {theme}</div>;
}

To solve this, defer browser checks using the useEffect Hook or client-safe skeletons:

// Good: Hydration safe browser check
'use client';
import { useState, useEffect } from 'react';

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

  useEffect(() => {
    // Runs only inside the browser after hydration compiles
    setTheme(localStorage.getItem('theme') || 'light');
  }, []);

  return <div>Active: {theme}</div>;
}

7. Gotchas of Client-Side Singletons

  • Memory Leakage: Subscribing to external stores inside Client Components without returning clean-up handlers (e.g. using unsubscribe callbacks inside useEffect) leaks event listeners, degrading browser performance.
  • Stale Data via Browser Navigation: Storing domain records in client-side memory means users won't see database changes when navigating back and forth unless you force fetch refreshes.

Key Takeaways

  • Next.js App Router relies on Server Components, reducing the need for global client stores.
  • Avoid declaring global state variables outside of React components on the server to prevent data leaks.
  • Leverage URL parameters for filter, sorting, and pagination state.
  • Defer browser checks (like reading localStorage) using useEffect to prevent hydration mismatch errors.
  • Use props or React Context to pass server data down to client trees.

Understanding state boundaries helps you minimize client bundle sizes. When complex client state is genuinely required, you must implement stores safely. In the next lesson, we will cover Zustand for Global Client State, learning how to configure request-safe Zustand stores inside App Router environments.

Chat with us