Next.js 15 서버 상태, 컨텍스트 및 Zustand 도달 시점의 상태 관리

작성자

카테고리:

← 피드로
DEV Community · Anas Sheikh · 2026-08-01 개발(SW)

For a long time I reached for Redux on every project out of habit, even ones that barely needed client state at all. Next.js 15 changed that. Most of what used to live in a global store now belongs on the server, and the client-side state that is left over is usually small enough that Redux is overkill.

Here is how I actually decide where a piece of state should live now.

1. Most State Does Not Belong on the Client Anymore

This is the biggest shift from older React apps. Data fetched from a database, user profile info, a list of posts, none of that needs a client-side store when it can just be fetched directly in a Server Component.

// app/dashboard/page.tsx
import { getUser } from '@/lib/queries/users';
import { getPosts } from '@/lib/queries/posts';

export default async function DashboardPage() {
  const user = await getUser();
  const posts = await getPosts(user.id);

  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      <PostList posts={posts} />
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

No useEffect, no loading state, no global store holding this data just so a few components can read it. It is fetched where it is used and passed down as props. This alone removes a large chunk of what used to require Redux or Context in older apps.

2. React Context for State That a Few Related Components Share

Context still has a place, specifically for state that a small, related group of components needs, and that does not change on every keystroke.

// context/ThemeContext.tsx
'use client';
import { createContext, useContext, useState } from 'react';

interface ThemeContextValue {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextValue | null>(null);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('dark');

  const toggleTheme = () => setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));

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

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) throw new Error('useTheme must be used inside ThemeProvider');
  return context;
}

Enter fullscreen mode Exit fullscreen mode

Theme, sidebar collapsed state, a modal that is open or closed, these fit Context well because they change rarely and only a handful of components care about them.

3. Where Context Starts to Hurt

Context re-renders every component reading it whenever the value changes, with no built-in way to subscribe to just part of the value. That is fine for something like theme, which barely changes. It becomes a real problem for state that updates often.

// This causes every consumer to re-render on every keystroke
const [searchState, setSearchState] = useState({ query: '', filters: {}, results: [] });

Enter fullscreen mode Exit fullscreen mode

If a search input, a filter panel, and a results list all read from one Context value like this, typing a single character re-renders all three, even the ones with nothing to do with the keystroke itself. This is usually the point where I switch to something built for frequent updates.

4. Zustand for State That Updates Often or Lives Across Unrelated Components

Zustand is what I reach for once Context starts causing unnecessary re-renders, or once state needs to be read and updated from components that are not naturally nested together.

// store/useCartStore.ts
import { create } from 'zustand';

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface CartStore {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
  total: () => number;
}

export const useCartStore = create<CartStore>((set, get) => ({
  items: [],
  addItem: (item) =>
    set((state) => ({
      items: [...state.items, item],
    })),
  removeItem: (id) =>
    set((state) => ({
      items: state.items.filter((item) => item.id !== id),
    })),
  total: () => get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}));

Enter fullscreen mode Exit fullscreen mode

// components/CartButton.tsx
'use client';
import { useCartStore } from '@/store/useCartStore';

export function CartButton() {
  const items = useCartStore((state) => state.items);
  return <button>Cart ({items.length})</button>;
}

Enter fullscreen mode Exit fullscreen mode

The key difference from Context is in that last line. useCartStore((state) => state.items) only re-renders this component when items specifically changes, not on every update to the store. A component reading total() elsewhere does not re-render just because a completely unrelated piece of cart state changed.

5. Combining Server State with Client State Cleanly

A common mistake is treating server-fetched data and client-only state as the same kind of thing. They are not, and mixing them causes stale data bugs.

// app/products/[id]/page.tsx
import { getProduct } from '@/lib/queries/products';
import { AddToCartButton } from '@/components/AddToCartButton';

export default async function ProductPage({ params }) {
  const { id } = await params;
  const product = await getProduct(id); // server state, fetched fresh

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton product={product} /> {/* client state, cart store */}
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

The product data comes fresh from the server on every request. The cart itself, what the user has actually added, is client state that persists across navigation and does not need refetching. Keeping these two clearly separate avoids the bug where client state accidentally goes stale because it was holding a copy of server data instead of just referencing it.

6. Persisting Client State Across Reloads

For state that should survive a page refresh, like a cart or draft form data, Zustand has a built-in persist middleware:

// store/useCartStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

export const useCartStore = create<CartStore>()(
  persist(
    (set, get) => ({
      items: [],
      addItem: (item) => set((state) => ({ items: [...state.items, item] })),
      removeItem: (id) =>
        set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
      total: () => get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
    }),
    { name: 'cart-storage' }
  )
);

Enter fullscreen mode Exit fullscreen mode

This saves the store to localStorage automatically and rehydrates it on load, without writing manual save and load logic for every field.

How I Actually Decide

Is it data from a database? Fetch it on the server. No store needed.

Does a small group of related components share it, and does it change rarely? Context.

Does it update often, or get read from components scattered across the app? Zustand.

Does it need to survive a page reload? Zustand with the persist middleware.

Most projects end up using all three at different points, server fetching for the bulk of the data, Context for a couple of UI-level toggles, and Zustand for the handful of things like a cart or a multi-step form that genuinely need fast, cross-component client state.

Summary

Tool Use it for Server Components Data from a database or API, no client store needed React Context Small groups of related components, infrequent updates Zustand Frequent updates, or state read across unrelated components Zustand + persist Client state that should survive a page reload

The real shift in Next.js 15 is not which state library wins, it is that most state does not need a client library at all anymore. Once the server-fetched data is out of the picture, what is left is usually small enough that the choice between Context and Zustand comes down to how often it changes.

I use this exact split, server fetching, Context for UI toggles, Zustand for anything cart-like, across the dashboards and templates I build.

See it in a real codebase:

Get the templates: https://pixelanas.gumroad.com

Do you still reach for Redux, or has Zustand replaced it for you too? Drop it below 👇

Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다