用Claude Code整理React状态管理:从Context到TanStack Query
用Claude Code整理React状态管理,区分客户端状态与服务端状态,并比较Context、Zustand、Jotai和TanStack Query。
React 状态管理最容易出问题的地方,不是没有用库,而是把所有会变化的数据都当成同一种东西。输入框内容、弹窗开关、购物车、主题设置、从 API 取回的商品列表,都叫 state,但它们的来源、生命周期和失败方式完全不同。
Claude Code 可以帮助重构这些代码,但前提是你给它清晰边界。如果只说“帮我优化状态管理”,它可能会把所有东西塞进一个全局 store,或者把 API 数据复制到客户端缓存里。更安全的做法是先让 Claude Code 盘点状态,再决定哪些保留在 React,哪些交给 Zustand、Jotai 或 TanStack Query。
这篇文章用初学者能理解的方式说明判断标准,并给出 todo、cart、settings、商品列表四个实例,包含可复制的 React/TypeScript 代码、测试、常见坑、官方链接和商业化 CTA。
先区分客户端状态和服务端状态
选择库之前,先回答一个问题:这个状态真正属于谁?属于当前组件,属于浏览器里的用户操作,还是属于服务器?
flowchart TD
A["React state management"] --> B["Local UI state"]
A --> C["Shared client state"]
A --> D["Persisted preferences"]
A --> E["Server state"]
B --> B1["input, modal, tab"]
C --> C1["cart, wizard, editor"]
D --> D1["theme, density, locale"]
E --> E1["products, orders, profile from API"]
本地 UI 状态适合放在组件附近,比如 tab、modal、输入中的文字。多个动作组成一个页面流程时,可以用 useReducer。如果远处的子组件都要读写同一个值,可以用 Context 或轻量 store。
客户端共享状态是在浏览器里产生的状态,例如购物车草稿、编辑器草稿、向导步骤。持久化偏好是主题、显示密度、语言等,通常可以存在 localStorage。服务端状态则来自 API,可能被其他用户、后台任务或另一个设备改变,所以需要重新获取、缓存、失效和错误处理。
React 内建能力什么时候够用
React 官方的 Managing State 强调先整理 state 结构、必要时提升到共同父组件,再用 reducer 和 context 扩展。这个顺序非常适合给 Claude Code 当约束。
| 需求 | 先用什么 | 什么时候考虑库 |
|---|---|---|
| 输入框、弹窗、tab | useState | 通常不需要 |
| 一个页面有多个动作 | useReducer | reducer 被多个页面复用 |
| 深层组件需要同一值 | Context | 高频更新导致大范围重渲染 |
| 购物车、编辑草稿 | Zustand | 多个远距离组件都要更新 |
| 主题、密度、侧栏 | Jotai | 很多小偏好需要组合 |
| 商品、订单、资料 API | TanStack Query | 需要缓存、重试、失效、刷新 |
实务判断可以更简单:同一个值是否在三个以上地方更新?是否跨页面?是否刷新后还要保留?是否来自 API?如果答案都是否,React 内建状态通常就足够。
实例1:todo 用 useReducer 和 Context 开始
todo 列表适合用来练习 reducer,因为它有添加、切换、删除等动作,但还不一定需要服务端同步。下面的写法把状态和 dispatch 分开,方便组件读取,也方便测试。
import {
createContext,
useContext,
useMemo,
useReducer,
type Dispatch,
type ReactNode,
} from "react";
export type Todo = {
id: string;
title: string;
done: boolean;
};
type TodoAction =
| { type: "added"; title: string }
| { type: "toggled"; id: string }
| { type: "deleted"; id: string };
export function todoReducer(todos: Todo[], action: TodoAction): Todo[] {
switch (action.type) {
case "added":
return [
...todos,
{ id: crypto.randomUUID(), title: action.title.trim(), done: false },
];
case "toggled":
return todos.map((todo) =>
todo.id === action.id ? { ...todo, done: !todo.done } : todo,
);
case "deleted":
return todos.filter((todo) => todo.id !== action.id);
default:
return todos;
}
}
const TodoStateContext = createContext<Todo[] | null>(null);
const TodoDispatchContext = createContext<Dispatch<TodoAction> | null>(null);
export function TodoProvider({ children }: { children: ReactNode }) {
const [todos, dispatch] = useReducer(todoReducer, []);
const stateValue = useMemo(() => todos, [todos]);
return (
<TodoStateContext.Provider value={stateValue}>
<TodoDispatchContext.Provider value={dispatch}>
{children}
</TodoDispatchContext.Provider>
</TodoStateContext.Provider>
);
}
export function useTodos() {
const value = useContext(TodoStateContext);
if (value === null) throw new Error("useTodos must be used in TodoProvider");
return value;
}
export function useTodoDispatch() {
const value = useContext(TodoDispatchContext);
if (value === null) {
throw new Error("useTodoDispatch must be used in TodoProvider");
}
return value;
}
这个结构很适合让 Claude Code 小步修改。例如“增加 edited action”“禁止空标题”“给 reducer 写测试”。因为动作名称清晰,review diff 时也更容易看出业务逻辑是否被改坏。
实例2:购物车用 Zustand 跨页面共享
购物车会被商品详情页、header 徽标、购物车抽屉和 checkout 同时使用。只用 Context 也可以实现,但当持久化、选择器和更新函数变多时,Zustand 会更直接。它通过 create 建立 hook store,persist middleware 可以保留刷新后的状态。
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
export type CartItem = {
productId: string;
name: string;
price: number;
quantity: number;
};
type CartState = {
items: CartItem[];
currency: "JPY" | "USD";
addItem: (item: Omit<CartItem, "quantity">) => void;
removeItem: (productId: string) => void;
setQuantity: (productId: string, quantity: number) => void;
clear: () => void;
total: () => number;
};
export const useCartStore = create<CartState>()(
persist(
(set, get) => ({
items: [],
currency: "USD",
addItem: (item) =>
set((state) => {
const current = state.items.find(
(entry) => entry.productId === item.productId,
);
if (current) {
return {
items: state.items.map((entry) =>
entry.productId === item.productId
? { ...entry, quantity: entry.quantity + 1 }
: entry,
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (productId) =>
set((state) => ({
items: state.items.filter((item) => item.productId !== productId),
})),
setQuantity: (productId, quantity) =>
set((state) => ({
items: state.items.map((item) =>
item.productId === productId
? { ...item, quantity: Math.max(1, quantity) }
: item,
),
})),
clear: () => set({ items: [] }),
total: () =>
get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}),
{
name: "cart-v1",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
items: state.items,
currency: state.currency,
}),
},
),
);
注意,浏览器里的 cart 不能作为最终价格依据。价格、库存、折扣、税费和支付结果必须由服务器确认。让 Claude Code 改这段时,要明确“只持久化 items 和 currency,不持久化 token 或用户敏感信息”。
实例3:设置界面用 Jotai 拆成小 atom
主题、显示密度、侧边栏开关、预览模式等设置很小,但组合很多。Jotai 的 atom 模型适合这种场景:每个设置一个 atom,展示文本或 CSS class 用派生 atom 计算。
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
export const themeAtom = atomWithStorage<"light" | "dark">(
"settings.theme",
"light",
);
export const densityAtom = atomWithStorage<"comfortable" | "compact">(
"settings.density",
"comfortable",
);
export const sidebarOpenAtom = atom(true);
export const settingsLabelAtom = atom((get) => {
const theme = get(themeAtom) === "dark" ? "dark theme" : "light theme";
const density =
get(densityAtom) === "compact" ? "compact layout" : "comfortable layout";
return `${theme}, ${density}`;
});
export const resetSettingsAtom = atom(null, (_get, set) => {
set(themeAtom, "light");
set(densityAtom, "comfortable");
set(sidebarOpenAtom, true);
});
Jotai 的坑是拆得过细。设置类状态很适合 atom,但复杂业务流程如果被拆成几十个 atom,反而不容易追踪。API 数据也不要用 atom 当缓存,应该放到 TanStack Query。
实例4:商品和订单交给 TanStack Query
商品列表、订单历史、用户资料都属于服务端状态。TanStack Query v5 的定位就是处理获取、缓存、同步和更新服务端状态。它比手写 useEffect + useState 更适合处理 loading、error、refetch、mutation 和 invalidation。
import {
QueryClient,
QueryClientProvider,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import type { ReactNode } from "react";
type Product = {
id: string;
name: string;
price: number;
stock: number;
};
const queryClient = new QueryClient();
export function ProductsProvider({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
async function fetchProducts(category: string): Promise<Product[]> {
const response = await fetch(`/api/products?category=${category}`);
if (!response.ok) throw new Error("Failed to fetch products");
return response.json() as Promise<Product[]>;
}
async function addCartLine(productId: string) {
const response = await fetch("/api/cart/lines", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ productId }),
});
if (!response.ok) throw new Error("Failed to add cart line");
return response.json();
}
export function useProducts(category: string) {
return useQuery({
queryKey: ["products", category],
queryFn: () => fetchProducts(category),
staleTime: 60_000,
});
}
export function useAddCartLine() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: addCartLine,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["cart"] });
},
});
}
边界是:Zustand 保存用户在浏览器里的临时购物意图,TanStack Query 获取服务器确认后的商品、购物车、订单和结账状态。不要让 Claude Code 把两者混在一个 store 里。
安全地让 Claude Code 重构
Claude Code 的官方说明中,How Claude Code works 描述了收集上下文、执行、验证的循环。状态管理重构也要按这个节奏来:先调查,后修改,最后验证。
请检查这个 React 应用的状态管理,先不要编辑文件。
目标:
- 区分 client state 和 server state
- 找出可以保留在 useState/useReducer/Context 的部分
- 找出适合 Zustand、Jotai、TanStack Query 的部分
输出:
- 状态清单表
- 重复或派生 state
- 把 API 数据当 client state 保存的地方
- 小步迁移顺序和测试建议
第二步只改一个区域:
只实现 todo 页面迁移到 useReducer + Context。
限制:
- 不改变路由和显示文案
- 不增加依赖
- 添加或更新 reducer 测试
- 最后报告修改文件和验证命令
对于购物车和 API 数据,还要加上领域约束,例如“不要持久化 token”“价格由服务器确认”“服务端状态使用 TanStack Query”。这些约束比“用哪个库”更重要。
测试、坑和下一步
重构状态管理时,先测试纯逻辑。下面的 reducer 测试不需要启动浏览器。
import { describe, expect, it, vi } from "vitest";
import { todoReducer, type Todo } from "./TodoProvider";
describe("todoReducer", () => {
it("adds a todo with a generated id", () => {
vi.spyOn(crypto, "randomUUID").mockReturnValue("todo-1");
const result = todoReducer([], { type: "added", title: "Write tests" });
expect(result).toEqual([
{ id: "todo-1", title: "Write tests", done: false },
]);
});
it("toggles a todo", () => {
const initial: Todo[] = [{ id: "todo-1", title: "Ship", done: false }];
const result = todoReducer(initial, { type: "toggled", id: "todo-1" });
expect(result[0].done).toBe(true);
});
});
常见坑有五个:把所有东西都放进全局 store;把 API 数据放进 Zustand 后手写缓存;同时保存 items 和 totalPrice 这类派生值;把敏感信息放进 localStorage;没有在刷新、失败请求、低速网络、另一个 tab 中验证。
官方资料可以看 React Managing State、Zustand persist、Jotai、TanStack Query overview 和 Claude Code best practices。站内继续阅读可以看更好的提示词指南和Claude Code 生产力技巧。
如果你想把这套流程变成团队标准,可以从 ClaudeCodeLab 产品与模板整理 CLAUDE.md、review prompt 和测试命令;真实项目迁移可以从 training / consultation 开始。
实际试用后,最有效的顺序是先处理 server state。API 数据移到 TanStack Query 后,Zustand 只负责真正的客户端状态,Jotai 只负责设置偏好。先让 Claude Code 输出状态清单和迁移顺序,再让它改代码,review 的负担明显小很多。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
Claude Code权限安全阶梯:逐步放开访问而不失控
从只读到有限编辑、验证命令和部署检查的 Claude Code 权限升级流程。
Claude Code 小PR证据包:让小改动真正可审查
用差异、验证命令、公开URL、CTA路径和回滚说明,把Claude Code的小PR变得可审查。
Claude Code 提交前 Review Gate:同时检查差异、测试、公开 URL 和 CTA
提交前用 Claude Code 审查差异范围、build、公开 URL、Gumroad 链接、咨询 CTA、缺少测试和无关文件。