Featured image of post React Server Components 下的状态管理策略

React Server Components 下的状态管理策略

用 RSC 之后,我扔掉了大半个 Redux。这篇聊聊在 Server Components 主导的架构里,状态到底该放哪。

背景:RSC 改变了什么

传统 React SPA 里,所有数据都要先 fetch 到客户端,再用状态管理库(Redux、Zustand、React Query)缓存和分发。

RSC 出现后,服务端组件可以直接 await 数据库查询,数据天然在服务端,根本不需要传到客户端再管理。

1
2
3
4
5
传统 SPA:
Browser → API → Redux Store → Component

RSC 架构:
Server Component → DB → HTML → Browser

这意味着大量"服务端数据的客户端缓存"需求直接消失了。


状态分类模型

在 RuiToolAI 项目里,我把状态分成四类:

类型存放位置工具
服务端数据Server Component直接 await
URL 参数URL search paramsnuqs
客户端全局内存Zustand
表单组件局部react-hook-form

原则很简单:能放服务端就放服务端,能放 URL 就放 URL,实在不行才用 Zustand。


服务端状态:直接读,不缓存

Server Component 里直接查数据库,不需要任何状态管理库。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// src/app/(sites)/history/page.tsx
import { getCloudflareContext } from "@opennextjs/cloudflare";
import { drizzle } from "drizzle-orm/d1";
import * as schema from "@/db/schema";
import { eq, desc } from "drizzle-orm";

export default async function HistoryPage() {
  const { env } = await getCloudflareContext();
  const db = drizzle(env.NEXT_TAG_CACHE_D1, { schema });

  // 直接查,不需要 useEffect、不需要 loading state
  const session = await getSessionFromCookie();
  const items = await db
    .select()
    .from(schema.generatedImages)
    .where(eq(schema.generatedImages.userId, session.user.id))
    .orderBy(desc(schema.generatedImages.createdAt))
    .limit(50);

  return <HistoryClient initialItems={items} />;
}

关键点:数据作为 initialItems prop 传给客户端组件,客户端组件只负责交互逻辑(删除、轮询状态更新)。

何时需要 revalidate

Server Component 的数据在请求时是新鲜的,但如果用了 Next.js 缓存,需要手动 revalidate:

1
2
3
4
5
6
7
8
9
// Server Action 里操作完数据后
import { revalidatePath } from "next/cache";

export const deleteImageAction = actionClient
  .inputSchema(deleteImageSchema)
  .action(async ({ parsedInput }) => {
    // ... 删除逻辑
    revalidatePath("/history");
  });

URL 状态:nuqs 管理搜索参数

URL 状态的好处:刷新不丢失、可分享、浏览器前进后退正常工作。

在 RuiToolAI 里,管理后台的筛选条件就放在 URL 里:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// src/app/(admin)/admin/users/page.tsx
import { parseAsString, parseAsInteger, useQueryStates } from "nuqs";

// Server Component 读取 URL 参数
export default async function UsersPage({
  searchParams,
}: {
  searchParams: { page?: string; search?: string };
}) {
  const page = Number(searchParams.page ?? 1);
  const search = searchParams.search ?? "";

  const users = await queryUsers({ page, search });
  return <UsersTable users={users} />;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Client Component 更新 URL 参数
"use client";
import { useQueryState } from "nuqs";

function SearchInput() {
  const [search, setSearch] = useQueryState("search", {
    defaultValue: "",
    shallow: false, // 触发服务端重新渲染
  });

  return (
    <input
      value={search}
      onChange={(e) => setSearch(e.target.value)}
      placeholder="搜索用户..."
    />
  );
}

shallow: false 会触发完整的服务端重新渲染,数据自动更新,不需要任何额外的状态管理。


客户端全局状态:Zustand 最小化

Zustand 只用于真正需要跨组件共享的客户端状态。在这个项目里主要是两个:

Session Store

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// src/state/session.ts
import { create } from "zustand";

interface SessionStore {
  user: User | null;
  credits: number;
  setUser: (user: User | null) => void;
  setCredits: (credits: number) => void;
}

export const useSessionStore = create<SessionStore>((set) => ({
  user: null,
  credits: 0,
  setUser: (user) => set({ user }),
  setCredits: (credits) => set({ credits }),
}));

Session 信息在 Layout 里初始化,之后客户端组件直接读取,不需要每次都请求服务端:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// src/components/session-provider.tsx
"use client";
import { useEffect } from "react";
import { useSessionStore } from "@/state/session";

export function SessionProvider({
  user,
  credits,
}: {
  user: User;
  credits: number;
}) {
  const { setUser, setCredits } = useSessionStore();

  useEffect(() => {
    setUser(user);
    setCredits(credits);
  }, [user, credits]);

  return null;
}

生成任务状态

多任务并发时,需要在生成页面和历史页面之间共享"进行中的任务数量":

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// src/state/generation.ts
import { create } from "zustand";

interface GenerationStore {
  pendingCount: number;
  increment: () => void;
  decrement: () => void;
}

export const useGenerationStore = create<GenerationStore>((set) => ({
  pendingCount: 0,
  increment: () => set((s) => ({ pendingCount: s.pendingCount + 1 })),
  decrement: () =>
    set((s) => ({ pendingCount: Math.max(0, s.pendingCount - 1) })),
}));

表单状态:react-hook-form 局部管理

表单状态是最局部的,用 react-hook-form 管理,不需要提升到全局:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// src/app/(auth)/sign-up/sign-up.client.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAction } from "next-safe-action/hooks";
import { signUpSchema, type SignUpSchema } from "@/schemas/signup.schema";
import { signUpAction } from "./sign-up.actions";

export function SignUpForm() {
  const form = useForm<SignUpSchema>({
    resolver: zodResolver(signUpSchema),
    defaultValues: { email: "", password: "" },
  });

  const { execute, status } = useAction(signUpAction, {
    onSuccess: () => {
      toast.success("注册成功!");
    },
    onError: ({ error }) => {
      toast.error(error.serverError ?? "注册失败");
    },
  });

  return (
    <form onSubmit={form.handleSubmit(execute)}>
      {/* 表单字段 */}
    </form>
  );
}

实战:生成历史页面的状态设计

历史页面是这个项目里状态最复杂的页面,综合用了所有四种状态:

1
2
3
4
5
6
7
服务端状态(initialItems)
    ↓ 传给客户端
客户端局部状态(items: useState)
    ↓ 轮询更新
Server Action(checkImageStatusAction)
    ↓ 完成后
revalidatePath(可选,刷新服务端缓存)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// src/app/(sites)/history/client.tsx
"use client";

export function HistoryClient({
  initialItems,
}: {
  initialItems: GeneratedImage[];
}) {
  // 客户端局部状态:从服务端初始值开始
  const [items, setItems] = useState(initialItems);
  const [failedExpanded, setFailedExpanded] = useState(false);

  // 找出所有 processing 的任务
  const processingIds = items
    .filter((item) => item.status === "processing" || item.status === "pending")
    .map((item) => item.id);

  // 轮询逻辑
  const pollAll = useCallback(async () => {
    for (const id of processingIds) {
      const result = await checkImageStatusAction({ id });
      if (result?.data) {
        setItems((prev) =>
          prev.map((item) => (item.id === id ? result.data! : item))
        );
      }
    }
  }, [processingIds]);

  useEffect(() => {
    if (processingIds.length === 0) return;
    pollAll();
    const interval = setInterval(pollAll, 4000);
    // tab 切换回来立即轮询
    const handleVisibility = () => {
      if (document.visibilityState === "visible") pollAll();
    };
    document.addEventListener("visibilitychange", handleVisibility);
    return () => {
      clearInterval(interval);
      document.removeEventListener("visibilitychange", handleVisibility);
    };
  }, [processingIds.length, pollAll]);

  // 删除
  const { execute: deleteImage } = useAction(deleteGeneratedImageAction, {
    onSuccess: ({ input }) => {
      setItems((prev) => prev.filter((item) => item.id !== input.id));
    },
  });

  // 渲染...
}

常见误区

误区 1:在 Server Component 里用 useState

1
2
3
4
5
6
7
8
9
// 错误:Server Component 不能用 hooks
export default async function Page() {
  const [data, setData] = useState(null); // 报错
}

// 正确:直接 await
export default async function Page() {
  const data = await fetchData();
}

误区 2:把服务端数据放进 Zustand

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 不必要:用 Zustand 缓存服务端数据
const useUserStore = create((set) => ({
  users: [],
  fetchUsers: async () => {
    const users = await fetch("/api/users").then((r) => r.json());
    set({ users });
  },
}));

// 直接在 Server Component 查询
export default async function UsersPage() {
  const users = await db.select().from(schema.users);
  return <UsersList users={users} />;
}

误区 3:URL 状态用 useState 管理

1
2
3
4
5
6
7
// 刷新后丢失
const [filter, setFilter] = useState("all");

// 放 URL 里,刷新不丢失,可分享
const [filter, setFilter] = useQueryState("filter", {
  defaultValue: "all",
});

总结

RSC 架构下的状态管理原则:

  1. 服务端数据 → Server Component 直接 await,不需要任何状态库
  2. URL 参数 → nuqs,刷新不丢失,支持分享
  3. 客户端全局 → Zustand,只放真正需要跨组件共享的少量状态
  4. 表单 → react-hook-form,局部管理

这套分层策略让代码更简单,性能更好,也更容易维护。


参考资源

使用 Hugo 构建
主题 StackJimmy 设计