第 15 章:状态管理(Zustand / Jotai / Redux Toolkit)

在 Next.js App Router 中选择合适的状态管理方案——深度对比 Zustand、Jotai、Redux Toolkit,理解服务端状态与客户端状态的边界,以及 URL 作为状态源的最佳实践。

本章目标:建立 Next.js 中状态管理的清晰心智模型——理解服务端状态 vs 客户端状态 vs URL 状态的边界,掌握 Zustand / Jotai / Redux Toolkit 的适用场景,并学会在 App Router 中正确使用全局状态。


15.1 状态分类心智模型

在 Next.js App Router 中,状态分为四类:

状态分类矩阵

状态类型           │ 存储位置         │ 工具                │ 示例
──────────────────┼─────────────────┼────────────────────┼───────────────────
服务端状态         │ 数据库 / API     │ RSC + fetch + cache │ 文章、用户、评论
URL 状态          │ URL              │ searchParams       │ 分页、筛选、搜索
客户端全局状态     │ 浏览器内存       │ Zustand / Jotai    │ 主题、购物车、通知
客户端局部状态     │ 组件内部         │ useState           │ 表单输入、UI 切换

关键原则

  1. 服务端状态优先:能从数据库获取的数据,不要存到客户端状态
  2. URL 状态优先:可分享、可书签的状态,应该放在 URL 中
  3. 客户端全局状态最小化:只存真正需要跨组件共享的 UI 状态
  4. 局部状态最常用:大多数场景用 useState 就够了

15.2 服务端状态(RSC + fetch)

使用 React cache 去重

// lib/services/article.ts

import { cache } from 'react';
import { prisma } from '@/lib/prisma';

// cache() 保证同一次渲染中,多次调用只执行一次查询
export const getArticle = cache(async (slug: string) => {
  return prisma.article.findUnique({
    where: { slug },
    include: { author: true },
  });
});

export const getArticles = cache(async (options: {
  page?: number;
  category?: string;
} = {}) => {
  // ...
});

使用 revalidatePath 刷新缓存

// app/actions/article.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function createArticle(formData: FormData) {
  await prisma.article.create({ data: { /* ... */ } });

  // 刷新列表页缓存
  revalidatePath('/articles');
}

Server Component 直接获取数据

// app/articles/[slug]/page.tsx

import { getArticle } from '@/lib/services/article';
import { notFound } from 'next/navigation';

export default async function ArticlePage({
  params,
}: {
  params: { slug: string };
}) {
  const article = await getArticle(params.slug);
  if (!article) notFound();

  return (
    <article>
      <h1>{article.title}</h1>
      <p>By {article.author.name}</p>
      <div dangerouslySetInnerHTML={{ __html: article.content }} />
    </article>
  );
}

15.3 URL 状态

searchParams 作为状态源

// app/articles/page.tsx

import { getArticles } from '@/lib/services/article';
import Link from 'next/link';

type Props = {
  searchParams: Promise<{
    page?: string;
    category?: string;
    q?: string;
    sort?: string;
  }>;
};

export default async function ArticlesPage({ searchParams }: Props) {
  const params = await searchParams;
  const page = Number(params.page) || 1;
  const category = params.category;
  const search = params.q;
  const sort = params.sort || 'newest';

  const { articles, pagination } = await getArticles({
    page,
    category,
    search,
    sort,
  });

  return (
    <div>
      {/* 筛选器:通过 Link 修改 URL 状态 */}
      <div className="flex gap-2 mb-6">
        {['all', 'frontend', 'backend', 'devops'].map((cat) => (
          <Link
            key={cat}
            href={`/articles?category=${cat === 'all' ? '' : cat}&q=${search || ''}`}
            className={`px-3 py-1 rounded-full text-sm ${
              (cat === 'all' && !category) || cat === category
                ? 'bg-primary text-primary-foreground'
                : 'bg-muted text-muted-foreground'
            }`}
          >
            {cat}
          </Link>
        ))}
      </div>

      {/* 排序 */}
      <div className="flex gap-4 mb-6">
        <Link
          href={`/articles?sort=newest&category=${category || ''}`}
          className={sort === 'newest' ? 'font-bold' : ''}
        >
          最新
        </Link>
        <Link
          href={`/articles?sort=popular&category=${category || ''}`}
          className={sort === 'popular' ? 'font-bold' : ''}
        >
          最热
        </Link>
      </div>

      {/* 文章列表 */}
      <div className="space-y-4">
        {articles.map((article) => (
          <ArticleCard key={article.id} article={article} />
        ))}
      </div>

      {/* 分页 */}
      <div className="flex gap-2 mt-8">
        {pagination.hasPrev && (
          <Link
            href={`/articles?page=${page - 1}&category=${category || ''}`}
            className="btn-outline btn-sm"
          >
            上一页
          </Link>
        )}
        {pagination.hasNext && (
          <Link
            href={`/articles?page=${page + 1}&category=${category || ''}`}
            className="btn-outline btn-sm"
          >
            下一页
          </Link>
        )}
      </div>
    </div>
  );
}

Client Component 中操作 URL 状态

// components/article/article-search.tsx
'use client';

import { useRouter, useSearchParams, usePathname } from 'next/navigation';
import { useTransition, useCallback } from 'react';
import { Input } from '@/components/ui/input';

export function ArticleSearch() {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const [isPending, startTransition] = useTransition();

  const createQueryString = useCallback(
    (name: string, value: string) => {
      const params = new URLSearchParams(searchParams.toString());
      if (value) {
        params.set(name, value);
      } else {
        params.delete(name);
      }
      // 搜索时重置到第一页
      if (name === 'q') {
        params.set('page', '1');
      }
      return params.toString();
    },
    [searchParams]
  );

  return (
    <Input
      placeholder="搜索文章..."
      defaultValue={searchParams.get('q') ?? ''}
      onChange={(e) => {
        startTransition(() => {
          router.push(`${pathname}?${createQueryString('q', e.target.value)}`);
        });
      }}
      className={isPending ? 'opacity-50' : ''}
    />
  );
}

15.4 客户端局部状态

useState 适用场景

'use client';

import { useState } from 'react';

export function ArticleForm() {
  // ✅ 表单输入
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');

  // ✅ UI 切换状态
  const [isPreview, setIsPreview] = useState(false);
  const [showAdvanced, setShowAdvanced] = useState(false);

  // ✅ 临时 UI 状态
  const [copied, setCopied] = useState(false);

  return (
    <form>
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      <button type="button" onClick={() => setIsPreview(!isPreview)}>
        {isPreview ? '编辑' : '预览'}
      </button>
    </form>
  );
}

useReducer 适用场景

'use client';

import { useReducer } from 'react';

type State = {
  items: { id: string; text: string; done: boolean }[];
  filter: 'all' | 'active' | 'completed';
};

type Action =
  | { type: 'ADD_ITEM'; text: string }
  | { type: 'TOGGLE_ITEM'; id: string }
  | { type: 'DELETE_ITEM'; id: string }
  | { type: 'SET_FILTER'; filter: State['filter'] };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'ADD_ITEM':
      return {
        ...state,
        items: [...state.items, { id: crypto.randomUUID(), text: action.text, done: false }],
      };
    case 'TOGGLE_ITEM':
      return {
        ...state,
        items: state.items.map((item) =>
          item.id === action.id ? { ...item, done: !item.done } : item
        ),
      };
    case 'DELETE_ITEM':
      return {
        ...state,
        items: state.items.filter((item) => item.id !== action.id),
      };
    case 'SET_FILTER':
      return { ...state, filter: action.filter };
    default:
      return state;
  }
}

export function TodoList() {
  const [state, dispatch] = useReducer(reducer, {
    items: [],
    filter: 'all',
  });

  const filtered = state.items.filter((item) => {
    if (state.filter === 'active') return !item.done;
    if (state.filter === 'completed') return item.done;
    return true;
  });

  return (
    <div>
      {/* 筛选 */}
      <div className="flex gap-2 mb-4">
        {(['all', 'active', 'completed'] as const).map((f) => (
          <button
            key={f}
            onClick={() => dispatch({ type: 'SET_FILTER', filter: f })}
            className={state.filter === f ? 'font-bold' : ''}
          >
            {f}
          </button>
        ))}
      </div>

      {/* 列表 */}
      {filtered.map((item) => (
        <div key={item.id} className="flex items-center gap-2">
          <input
            type="checkbox"
            checked={item.done}
            onChange={() => dispatch({ type: 'TOGGLE_ITEM', id: item.id })}
          />
          <span className={item.done ? 'line-through text-gray-400' : ''}>
            {item.text}
          </span>
          <button onClick={() => dispatch({ type: 'DELETE_ITEM', id: item.id })}>
            ×
          </button>
        </div>
      ))}
    </div>
  );
}

15.5 React Context

适用场景

Context 适合传递低频更新的全局值——主题、语言、用户信息:

// contexts/locale-context.tsx
'use client';

import { createContext, useContext, useState } from 'react';

type Locale = 'zh' | 'en';

type LocaleContextType = {
  locale: Locale;
  setLocale: (locale: Locale) => void;
  t: (key: string) => string;
};

const translations: Record<Locale, Record<string, string>> = {
  zh: { greeting: '你好', farewell: '再见' },
  en: { greeting: 'Hello', farewell: 'Goodbye' },
};

const LocaleContext = createContext<LocaleContextType | undefined>(undefined);

export function LocaleProvider({ children }: { children: React.ReactNode }) {
  const [locale, setLocale] = useState<Locale>('zh');

  const t = (key: string) => translations[locale][key] ?? key;

  return (
    <LocaleContext.Provider value={{ locale, setLocale, t }}>
      {children}
    </LocaleContext.Provider>
  );
}

export function useLocale() {
  const context = useContext(LocaleContext);
  if (!context) {
    throw new Error('useLocale must be used within LocaleProvider');
  }
  return context;
}

Context 的局限

// ❌ 高频更新的值不适合用 Context(会导致所有消费者重新渲染)
const [mouseX, setMouseX] = useState(0); // 每次鼠标移动都触发全局重渲染

// ❌ 复杂状态逻辑不适合 Context(逻辑分散、难测试)

// ✅ 推荐:高频 / 复杂状态使用 Zustand 或 Jotai

15.6 Zustand(推荐方案)

安装

npm install zustand

基础 Store

// stores/cart-store.ts

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

type CartItem = {
  id: string;
  name: string;
  price: number;
  quantity: number;
  image: string;
};

type CartStore = {
  items: CartItem[];
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: string) => void;
  updateQuantity: (id: string, quantity: number) => void;
  clearCart: () => void;
  totalItems: () => number;
  totalPrice: () => number;
};

export const useCartStore = create<CartStore>()(
  persist(
    (set, get) => ({
      items: [],

      addItem: (item) =>
        set((state) => {
          const existing = state.items.find((i) => i.id === item.id);
          if (existing) {
            return {
              items: state.items.map((i) =>
                i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
              ),
            };
          }
          return { items: [...state.items, { ...item, quantity: 1 }] };
        }),

      removeItem: (id) =>
        set((state) => ({
          items: state.items.filter((i) => i.id !== id),
        })),

      updateQuantity: (id, quantity) =>
        set((state) => ({
          items: quantity <= 0
            ? state.items.filter((i) => i.id !== id)
            : state.items.map((i) => (i.id === id ? { ...i, quantity } : i)),
        })),

      clearCart: () => set({ items: [] }),

      totalItems: () => get().items.reduce((sum, i) => sum + i.quantity, 0),
      totalPrice: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
    }),
    {
      name: 'cart-storage', // localStorage key
      storage: createJSONStorage(() => localStorage),
    }
  )
);

在组件中使用

// components/cart/cart-button.tsx
'use client';

import { useCartStore } from '@/stores/cart-store';
import { Button } from '@/components/ui/button';

export function CartButton() {
  // ✅ 只订阅需要的值,避免不必要的重渲染
  const totalItems = useCartStore((state) => state.totalItems());

  return (
    <Button variant="ghost" size="icon" className="relative">
      🛒
      {totalItems > 0 && (
        <span className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-destructive text-destructive-foreground text-2xs flex items-center justify-center">
          {totalItems}
        </span>
      )}
    </Button>
  );
}
// components/cart/cart-sheet.tsx
'use client';

import { useCartStore } from '@/stores/cart-store';
import { Button } from '@/components/ui/button';
import {
  Sheet,
  SheetContent,
  SheetHeader,
  SheetTitle,
} from '@/components/ui/sheet';

export function CartSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
  const items = useCartStore((state) => state.items);
  const totalPrice = useCartStore((state) => state.totalPrice());
  const removeItem = useCartStore((state) => state.removeItem);
  const updateQuantity = useCartStore((state) => state.updateQuantity);
  const clearCart = useCartStore((state) => state.clearCart);

  return (
    <Sheet open={open} onOpenChange={onClose}>
      <SheetContent>
        <SheetHeader>
          <SheetTitle>购物车 ({items.length})</SheetTitle>
        </SheetHeader>

        <div className="mt-6 space-y-4">
          {items.length === 0 ? (
            <p className="text-muted-foreground text-center py-8">购物车为空</p>
          ) : (
            <>
              {items.map((item) => (
                <div key={item.id} className="flex items-center gap-3">
                  <div className="w-12 h-12 bg-muted rounded-lg" />
                  <div className="flex-1">
                    <p className="text-sm font-medium">{item.name}</p>
                    <p className="text-sm text-muted-foreground">¥{item.price}</p>
                  </div>
                  <div className="flex items-center gap-2">
                    <Button
                      variant="outline"
                      size="sm"
                      onClick={() => updateQuantity(item.id, item.quantity - 1)}
                    >
                      -
                    </Button>
                    <span className="text-sm w-6 text-center">{item.quantity}</span>
                    <Button
                      variant="outline"
                      size="sm"
                      onClick={() => updateQuantity(item.id, item.quantity + 1)}
                    >
                      +
                    </Button>
                  </div>
                  <Button
                    variant="ghost"
                    size="icon"
                    onClick={() => removeItem(item.id)}
                  >
                    ×
                  </Button>
                </div>
              ))}

              <div className="border-t pt-4">
                <div className="flex justify-between font-semibold">
                  <span>合计</span>
                  <span>¥{totalPrice.toFixed(2)}</span>
                </div>
                <Button className="w-full mt-4">结算</Button>
                <Button variant="ghost" className="w-full mt-2" onClick={clearCart}>
                  清空购物车
                </Button>
              </div>
            </>
          )}
        </div>
      </SheetContent>
    </Sheet>
  );
}

UI 状态 Store

// stores/ui-store.ts

import { create } from 'zustand';

type UIStore = {
  // 侧边栏
  sidebarOpen: boolean;
  toggleSidebar: () => void;
  setSidebarOpen: (open: boolean) => void;

  // 模态框
  activeModal: string | null;
  openModal: (id: string) => void;
  closeModal: () => void;

  // 通知
  notifications: { id: string; message: string; type: 'success' | 'error' | 'info' }[];
  addNotification: (message: string, type?: 'success' | 'error' | 'info') => void;
  removeNotification: (id: string) => void;
};

export const useUIStore = create<UIStore>()((set) => ({
  // 侧边栏
  sidebarOpen: false,
  toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
  setSidebarOpen: (open) => set({ sidebarOpen: open }),

  // 模态框
  activeModal: null,
  openModal: (id) => set({ activeModal: id }),
  closeModal: () => set({ activeModal: null }),

  // 通知
  notifications: [],
  addNotification: (message, type = 'info') =>
    set((state) => ({
      notifications: [
        ...state.notifications,
        { id: crypto.randomUUID(), message, type },
      ],
    })),
  removeNotification: (id) =>
    set((state) => ({
      notifications: state.notifications.filter((n) => n.id !== id),
    })),
}));

异步 Store(服务端状态同步)

// stores/article-store.ts

import { create } from 'zustand';

type Article = {
  id: string;
  title: string;
  slug: string;
  excerpt: string;
};

type ArticleStore = {
  articles: Article[];
  loading: boolean;
  error: string | null;
  fetchArticles: () => Promise<void>;
  optimisticAdd: (article: Article) => void;
};

export const useArticleStore = create<ArticleStore>()((set) => ({
  articles: [],
  loading: false,
  error: null,

  fetchArticles: async () => {
    set({ loading: true, error: null });
    try {
      const res = await fetch('/api/v1/articles');
      const data = await res.json();
      set({ articles: data.data, loading: false });
    } catch (error) {
      set({ error: 'Failed to fetch articles', loading: false });
    }
  },

  // 乐观更新
  optimisticAdd: (article) =>
    set((state) => ({
      articles: [article, ...state.articles],
    })),
}));

Zustand 最佳实践

// ✅ 选择性订阅,避免不必要的重渲染
const totalItems = useCartStore((state) => state.totalItems());

// ❌ 订阅整个 store
const store = useCartStore(); // 任何变化都会重渲染

// ✅ 使用 selector 组合
const cartSummary = useCartStore((state) => ({
  total: state.totalPrice(),
  count: state.totalItems(),
}));

// ✅ 在组件外部使用(如 Server Action 回调中)
import { useCartStore } from '@/stores/cart-store';

function handleClick() {
  useCartStore.getState().addItem({ id: '1', name: 'Product', price: 99, image: '' });
}

15.7 Jotai(原子化状态)

安装

npm install jotai

基础 Atom

// stores/atoms.ts

import { atom } from 'jotai';

// 基础 Atom
export const countAtom = atom(0);
export const themeAtom = atom<'light' | 'dark'>('light');

// 只读派生 Atom
export const doubleCountAtom = atom((get) => get(countAtom) * 2);

// 读写派生 Atom
export const celsiusAtom = atom(0);
export const fahrenheitAtom = atom(
  (get) => get(celsiusAtom) * 9 / 5 + 32,
  (get, set, newValue: number) => set(celsiusAtom, (newValue - 32) * 5 / 9)
);

在组件中使用

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

import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { countAtom, doubleCountAtom } from '@/stores/atoms';

export function Counter() {
  const [count, setCount] = useAtom(countAtom);
  const doubleCount = useAtomValue(doubleCountAtom);

  return (
    <div>
      <p>Count: {count}</p>
      <p>Double: {doubleCount}</p>
      <button onClick={() => setCount((c) => c + 1)}>+1</button>
      <button onClick={() => setCount((c) => c - 1)}>-1</button>
    </div>
  );
}

异步 Atom

// stores/atoms/article.ts

import { atom } from 'jotai';

type Article = { id: string; title: string };

// 异步 Atom
export const articlesAtom = atom<Article[]>([]);

export const fetchArticlesAtom = atom(
  null,
  async (_get, set) => {
    const res = await fetch('/api/v1/articles');
    const data = await res.json();
    set(articlesAtom, data.data);
  }
);

Jotai vs Zustand 对比

维度JotaiZustand
状态模型原子化(多个独立 Atom)单一 Store
重渲染粒度极细(只重渲染使用特定 Atom 的组件)细(通过 selector 控制)
派生状态内置(atom(get))需要手动(get())
持久化jotai-persistzustand/middleware
DevToolsReact DevToolsZustand DevTools
适合场景分散的状态、细粒度更新集中的状态、复杂逻辑
学习曲线

15.8 Redux Toolkit

安装

npm install @reduxjs/toolkit react-redux

配置 Store

// store/index.ts

import { configureStore } from '@reduxjs/toolkit';
import { cartReducer } from './features/cart-slice';
import { articleReducer } from './features/article-slice';

export const store = configureStore({
  reducer: {
    cart: cartReducer,
    article: articleReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

创建 Slice

// store/features/cart-slice.ts

import { createSlice, PayloadAction } from '@reduxjs/toolkit';

type CartItem = {
  id: string;
  name: string;
  price: number;
  quantity: number;
};

type CartState = {
  items: CartItem[];
};

const initialState: CartState = {
  items: [],
};

const cartSlice = createSlice({
  name: 'cart',
  initialState,
  reducers: {
    addItem: (state, action: PayloadAction<Omit<CartItem, 'quantity'>>) => {
      const existing = state.items.find((i) => i.id === action.payload.id);
      if (existing) {
        existing.quantity += 1;
      } else {
        state.items.push({ ...action.payload, quantity: 1 });
      }
    },
    removeItem: (state, action: PayloadAction<string>) => {
      state.items = state.items.filter((i) => i.id !== action.payload);
    },
    clearCart: (state) => {
      state.items = [];
    },
  },
});

export const { addItem, removeItem, clearCart } = cartSlice.actions;
export const cartReducer = cartSlice.reducer;

Provider 配置

// app/providers.tsx
'use client';

import { Provider } from 'react-redux';
import { store } from '@/store';

export function Providers({ children }: { children: React.ReactNode }) {
  return <Provider store={store}>{children}</Provider>;
}

使用 Typed Hooks

// store/hooks.ts

import { useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './index';

export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
// components/cart.tsx
'use client';

import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { addItem, removeItem } from '@/store/features/cart-slice';

export function Cart() {
  const dispatch = useAppDispatch();
  const items = useAppSelector((state) => state.cart.items);

  return (
    <div>
      {items.map((item) => (
        <div key={item.id}>
          <span>{item.name} x {item.quantity}</span>
          <button onClick={() => dispatch(removeItem(item.id))}>移除</button>
        </div>
      ))}
    </div>
  );
}

15.9 方案选型决策

决策矩阵

你的状态...
├── 来自服务端(数据库 / API)?
│   └── ✅ RSC + fetch + cache(不需要客户端状态管理)
│
├── 可分享 / 可书签(筛选、分页、搜索)?
│   └── ✅ URL searchParams
│
├── 只在单个组件内使用?
│   └── ✅ useState / useReducer
│
├── 低频更新的全局值(主题、语言)?
│   └── ✅ React Context
│
├── 需要跨组件共享的 UI 状态?
│   ├── 简单场景 → ✅ Zustand
│   ├── 分散的原子化状态 → ✅ Jotai
│   └── 复杂业务 / 团队已有经验 → ✅ Redux Toolkit
│
└── 需要在组件外访问(如中间件)?
    └── ✅ Zustand(可在任意 JS 代码中使用)

常见场景推荐

场景推荐方案
博客 / 内容站RSC + URL 状态(几乎不需要客户端状态)
SaaS 仪表盘Zustand(侧边栏、模态框、通知)
电商购物车Zustand + persist(跨页面持久化)
在线编辑器Jotai(大量分散的细粒度状态)
企业后台管理Redux Toolkit(团队熟悉、复杂业务逻辑)
社交应用Zustand + TanStack Query(服务端状态 + 客户端状态)

15.10 实战:仪表盘状态管理

UI Store

// stores/dashboard-store.ts

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

type DashboardStore = {
  // 侧边栏
  sidebarCollapsed: boolean;
  toggleSidebar: () => void;

  // 选中的日期范围
  dateRange: { from: string; to: string };
  setDateRange: (range: { from: string; to: string }) => void;

  // 表格偏好
  tablePageSize: number;
  setTablePageSize: (size: number) => void;

  // 最近访问
  recentPages: { path: string; title: string; timestamp: number }[];
  addRecentPage: (page: { path: string; title: string }) => void;
};

export const useDashboardStore = create<DashboardStore>()(
  persist(
    (set) => ({
      // 侧边栏
      sidebarCollapsed: false,
      toggleSidebar: () =>
        set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),

      // 日期范围(默认最近 30 天)
      dateRange: {
        from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
        to: new Date().toISOString(),
      },
      setDateRange: (range) => set({ dateRange: range }),

      // 表格
      tablePageSize: 20,
      setTablePageSize: (size) => set({ tablePageSize: size }),

      // 最近访问
      recentPages: [],
      addRecentPage: (page) =>
        set((state) => ({
          recentPages: [
            { ...page, timestamp: Date.now() },
            ...state.recentPages.filter((p) => p.path !== page.path),
          ].slice(0, 10), // 最多 10 个
        })),
    }),
    {
      name: 'dashboard-prefs',
    }
  )
);

在布局中使用

// app/dashboard/layout.tsx

import { DashboardSidebar } from '@/components/dashboard/sidebar';
import { DashboardHeader } from '@/components/dashboard/header';

export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex min-h-screen">
      <DashboardSidebar />
      <div className="flex-1 flex flex-col">
        <DashboardHeader />
        <main className="flex-1 p-6">{children}</main>
      </div>
    </div>
  );
}
// components/dashboard/sidebar.tsx
'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useDashboardStore } from '@/stores/dashboard-store';
import { cn } from '@/lib/utils';

const navItems = [
  { path: '/dashboard', label: '概览', icon: '📊' },
  { path: '/dashboard/articles', label: '文章', icon: '📝' },
  { path: '/dashboard/comments', label: '评论', icon: '💬' },
  { path: '/dashboard/analytics', label: '分析', icon: '📈' },
  { path: '/dashboard/settings', label: '设置', icon: '⚙️' },
];

export function DashboardSidebar() {
  const pathname = usePathname();
  const collapsed = useDashboardStore((state) => state.sidebarCollapsed);

  return (
    <aside
      className={cn(
        'h-screen sticky top-0 border-r border-border bg-card transition-all duration-300',
        collapsed ? 'w-16' : 'w-64'
      )}
    >
      <div className="p-4">
        <h2 className={cn('font-bold', collapsed && 'hidden')}>
          Dashboard
        </h2>
      </div>

      <nav className="space-y-1 px-2">
        {navItems.map((item) => {
          const isActive = pathname === item.path;

          return (
            <Link
              key={item.path}
              href={item.path}
              className={cn(
                'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors',
                isActive
                  ? 'bg-primary text-primary-foreground'
                  : 'text-muted-foreground hover:bg-muted'
              )}
            >
              <span>{item.icon}</span>
              {!collapsed && <span>{item.label}</span>}
            </Link>
          );
        })}
      </nav>
    </aside>
  );
}

本章小结

Key Takeaways

  1. 状态管理的首要问题是分类:服务端状态、URL 状态、全局客户端状态、局部状态
  2. Next.js 中大多数状态不需要客户端管理:RSC + URL 状态可以覆盖 80% 的场景
  3. Zustand 是 Next.js 客户端状态的首选:简单、灵活、支持 persist 和组件外访问
  4. Jotai 适合原子化状态:细粒度更新、派生状态方便
  5. Redux Toolkit 适合复杂业务:团队已有经验、大量 reducer 逻辑
  6. 选择性订阅是性能关键:避免订阅整个 store,只选需要的值

下一步

下一章我们将深入 表单与验证——使用 React Hook Form + Zod 构建类型安全的表单系统,涵盖动态表单、文件上传、异步验证、多步骤表单等高级场景。


参考资料

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页