本节目标:状态一旦跨组件共享,类型问题就从「推导」变成了「契约与边界」。你会掌握 Context 默认值的三选一及其后果、用自定义 Hook 把判空收窄到一处的写法、泛型 Context 工厂与 Provider 值稳定性的关系;再进入 Zustand 与 Redux Toolkit,搞清楚
create的柯里化签名为什么存在、selector 如何决定重渲染粒度、slices 与中间件为什么要写StateCreator,以及RootState/AppDispatch/PayloadAction这套三件套各自解决什么。读完本节,你应该能在选型时不只看 API 手感,还能预判类型维护成本。
11.3 Context 与状态管理(Zustand / RTK)
上一节的状态都活在单个组件里,useState 一写就完事。跨组件共享之后,问题立刻变了形状:状态的定义位置与使用位置分离了,于是「这个值一定存在吗」「改这个值会重渲染哪些组件」「类型从哪里来」变成了必须显式回答的三个问题。Context、Zustand、Redux Toolkit 对这三个问题的回答完全不同,本节按这个顺序展开。
先看共享状态的第一行代码,几乎所有人都会在这里踩一次:
type AuthState = { user: User | null; login: (name: string) => Promise<void> };
// 写法一:默认 null,消费点必须判空
const AuthContext = createContext<AuthState | null>(null);
// 写法二:造一个"看起来能用"的默认值
const AuthContext2 = createContext<AuthState>({
user: null,
login: async () => {},
});
两种写法都能编译,但都埋了坑,而且第二种更危险——它让「忘了包 Provider」这个错误在类型层面完全隐形。
11.3.1 默认值的三选一
createContext<T>(defaultValue) 里的 defaultValue 只在一个场景下会被真正读到:消费组件上方没有对应的 Provider。理解这一点,三种默认值写法的取舍就清楚了。
| 默认值 | 消费点类型 | 漏包 Provider 时的表现 | 结论 |
|---|---|---|---|
null | T | null | 运行时报 Cannot read properties of null | 可用但污染所有消费点 |
undefined | T | undefined | 运行时报错(可主动抛出可读信息) | 推荐 |
假默认值 {} | T | 静默失效:login() 调用了个空函数 | 禁用 |
为什么推荐 undefined 而不是 null?因为 null 在业务里往往有合法含义——「未登录」的用户就是 null。如果 context 的默认值也用 null,你就无法区分「没有 Provider」和「有 Provider 但用户未登录」。undefined 几乎不会作为业务值出现,正好适合表达「缺失」。
假默认值的危害值得单独强调。上面写法二里,login: async () => {} 让类型完全成立,于是:
function Header() {
const { user, login } = useContext(AuthContext2);
return <button onClick={() => login('ada')}>登录</button>; // 点了没反应
}
没有报错、没有警告,点击按钮什么也不发生。这类问题在联调时极难定位,因为「代码看起来是对的」。宁可让类型带上 | undefined,也不要造假默认值。
11.3.2 把判空收窄到一处
createContext<AuthState | undefined>(undefined) 解决了语义问题,但代价是每个消费点都得处理 undefined:
const auth = useContext(AuthContext);
auth.user;
// TS18047: 'auth' is possibly 'null' or 'undefined'.
正解不是在每个组件里判空,而是封装一个自定义 Hook,把收窄做一次:
const AuthContext = createContext<AuthState | undefined>(undefined);
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (ctx === undefined) {
throw new Error('useAuth 必须在 <AuthProvider> 内使用');
}
return ctx; // 收窄为 AuthState
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = useCallback(async (name: string) => {
setUser({ id: '1', name });
}, []);
const value = useMemo<AuthState>(() => ({ user, login }), [user, login]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
这个模式带来三个收益,值得逐条对照:
- 消费点拿到的是精确类型。
useAuth().user是User | null,不是User | null | undefined,少一层判空。 - 错误信息可读。 漏包 Provider 时抛的是「useAuth 必须在 AuthProvider 内使用」,而不是
Cannot read properties of undefined。 - Context 本身不再被外部引用。
AuthContext不导出,只有useAuth和AuthProvider是公开 API,将来把实现换成 Zustand 时调用点一行都不用改。
第 3 点是这个模式最被低估的价值:Context 是实现细节,Hook 才是接口。
11.3.3 泛型 Context 工厂与值稳定性
同一个模式往往要在多个 Context 上重复。把它抽成工厂,类型参数只写一次:
function createCtxFactory<T>() {
const Ctx = createContext<T | undefined>(undefined);
function useCtx(): T {
const value = useContext(Ctx);
if (value === undefined) throw new Error('Provider 缺失');
return value;
}
return [Ctx.Provider, useCtx] as const;
}
type ThemeCtx = { theme: 'light' | 'dark'; toggle: () => void };
export const [ThemeProvider, useTheme] = createCtxFactory<ThemeCtx>();
as const 在这里不能省:不加的话返回类型是 (Provider<T> | (() => T))[],解构出来的 useTheme 是个联合类型,调用即报 TS2349: This expression is not callable.——这正是 11.2.7 讲过的元组返回值陷阱,在工厂函数里同样适用。
值稳定性是 Context 最容易出性能问题的地方,而且类型层面完全看不出来。 Provider 的 value 是引用比较的,每次渲染都新建对象会让所有 consumer 无条件重渲染:
// 每次 AuthProvider 渲染都新建对象 → 所有 consumer 重渲染
<AuthContext.Provider value={{ user, login }}>{children}</AuthContext.Provider>
// 正解:用 useMemo 固定引用,依赖只列真正参与的值
const value = useMemo<AuthState>(() => ({ user, login }), [user, login]);
注意 useMemo 的依赖数组不被类型检查,漏写 login 会让闭包里的 login 永远是旧引用。这也是为什么上一节强调 useCallback 的稳定性:login 稳定了,value 才有意义。
11.3.4 Context 承载状态机:Dispatch 的类型
Context 里放 useReducer 的返回值,是「中小型应用不引状态库」的常见做法。类型上只需要把 State 和 Action 一起导出:
import { createContext, useContext, useReducer, type Dispatch } from 'react';
type State = { status: 'idle' } | { status: 'loading' } | { status: 'done'; items: string[] };
type Action = { type: 'fetch' } | { type: 'success'; items: string[] };
type Store = { state: State; dispatch: Dispatch<Action> };
const StoreContext = createContext<Store | undefined>(undefined);
export function useStore(): Store {
const ctx = useContext(StoreContext);
if (ctx === undefined) throw new Error('StoreProvider 缺失');
return ctx;
}
Dispatch<Action> 是 React 自带的类型,等价于 (value: Action) => void。不要手写 (action: any) => void——那会让 11.2.2 讲的所有判别联合收益归零。另外注意这里的 Store 拆成了 state 与 dispatch 两个字段,而不是展开成 { status, items, dispatch }:保持 state 的联合结构完整,消费方才能用 switch (state.status) 做收窄。
11.3.5 Zustand:create 的类型与柯里化签名
Zustand 的类型模型比 Context 简单得多:store 就是一个对象类型,create<T>() 里的 T 是唯一需要写的地方。
import { create } from 'zustand';
type BearState = {
bears: number;
increase: (by: number) => void;
};
export const useBearStore = create<BearState>()((set) => ({
bears: 0,
increase: (by) => set((s) => ({ bears: s.bears + by })),
}));
注意 create<BearState>()(...) 是柯里化写法:先传类型参数,再传初始化函数。为什么不写成 create<BearState>((set) => ...)?因为当你要套中间件时,create<BearState>(devtools(...)) 的类型参数无法被正确推导——中间件会改变 set 的签名。柯里化把「类型参数」与「实现」分开,让中间件链的类型得以保留:
import { devtools, persist } from 'zustand/middleware';
export const useBearStore = create<BearState>()(
devtools(
persist((set) => ({ bears: 0, increase: (by) => set((s) => ({ bears: s.bears + by })) }), {
name: 'bear-storage',
}),
{ name: 'BearStore' },
),
);
只要用了中间件,就必须写柯里化形式,否则会遇到 set 的类型退化或 TS2345 之类的赋值错误。这是 Zustand 使用者最常卡住的一处。
selector 决定了组件的重渲染粒度,类型推导则完全自动:
const bears = useBearStore((s) => s.bears); // number,只订阅 bears
const increase = useBearStore((s) => s.increase); // (by: number) => void
// 陷阱:selector 返回新对象。每次 store 更新都构造新对象,引用比较永远不等,
// 该组件每次都重渲染;zustand v5 下还会看到 getSnapshot should be cached 的警告
const picked = useBearStore((s) => ({ bears: s.bears, increase: s.increase }));
// 正解:useShallow 做浅比较
import { useShallow } from 'zustand/react/shallow';
const shallow = useBearStore(useShallow((s) => ({ bears: s.bears, increase: s.increase })));
这条陷阱值得和 11.2.6 的 useSyncExternalStore 对照着看:Zustand 内部就是用 useSyncExternalStore 订阅 store 的,所以「快照必须缓存」的约束以「selector 必须返回稳定引用」的形式传递到了使用方。类型层面永远拦不住这类错误,判断标准是「这个 selector 的返回值会不会每次都变」。
11.3.6 Zustand 的 slices 与 StateCreator
store 变大之后要拆成 slices,这时就绕不开 StateCreator。它的签名是 StateCreator<Store, Mutators, Middlewares, Slice>:
import type { StateCreator } from 'zustand';
export type BearSlice = { bears: number; addBear: () => void };
export type FishSlice = { fishes: number; addFish: () => void };
export type Store = BearSlice & FishSlice;
export const createBearSlice: StateCreator<Store, [], [], BearSlice> = (set) => ({
bears: 0,
addBear: () => set((s) => ({ bears: s.bears + 1 })),
});
export const createFishSlice: StateCreator<Store, [], [], FishSlice> = (set) => ({
fishes: 0,
addFish: () => set((s) => ({ fishes: s.fishes + 1 })),
});
export const useStore = create<Store>()((...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
}));
三个类型参数各自的含义必须记住,否则套中间件时会一直报错:
| 参数位 | 含义 | 无中间件时 | 用 devtools 时 |
|---|---|---|---|
| 1 | 完整 store 类型 | Store | Store |
| 2 | mutators(中间件对 set 的扩展) | [] | [['zustand/devtools', never]] |
| 3 | 中间件链 | [] | [] |
| 4 | 本 slice 的类型 | BearSlice | BearSlice |
写 slice 时最容易犯的错是第一个参数只写 BearSlice——那样 slice 内部就读不到 fishes,跨 slice 调用会报 TS2339: Property 'fishes' does not exist on type 'BearSlice & { bears: number; addBear: () => void }'。第一个参数永远是完整 Store,第四个参数才是自己这一片。
11.3.7 Redux Toolkit:三件套的由来
RTK 的类型维护成本比 Zustand 高,但换来的是约束力。它的类型体系围绕三个导出展开,理解它们的来源比背诵写法更重要:
// store.ts
import { configureStore } from '@reduxjs/toolkit';
import authReducer from './authSlice';
import cartReducer from './cartSlice';
export const store = configureStore({
reducer: { auth: authReducer, cart: cartReducer },
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
RootState 和 AppDispatch 都是从 store 实例反推的,不需要手写。原因很直接:configureStore 返回的 store 类型里编码了完整的 reducer 树,手写一遍等于维护两份真相,任何一处 reducer 增删都会让两者不一致。
接下来是类型化 hooks,这一步不能省:
// hooks.ts
import { useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
withTypes 是 RTK 9.1 起提供的写法。更早的版本要写成 export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;。省掉这一步的后果是 selector 的 state 参数没有类型:
const user = useSelector((s) => s.auth.user);
// TS18046: 's' is of type 'unknown'.
TS18046: 'state' is of type 'unknown' 是 RTK 新手最常见的一条报错,成因就是没用类型化 hooks。
循环引用是这套结构的一个真实风险:store.ts 导出 RootState,authSlice.ts 需要 RootState(做跨 slice 的 selector),而 store.ts 又 import authSlice。解法是把 RootState 的定义挪到独立的 types.ts(由 slice 的类型手工组装),代价是这份类型要与 configureStore 的 reducer 树保持同步。两害相权,中小项目用 ReturnType<typeof store.getState>、并约定 slice 内部不做跨 slice 读取,通常是更省事的选择。
11.3.8 createSlice 与 PayloadAction
createSlice 的类型信息主要来自 initialState,action 的 payload 则要靠 PayloadAction<T> 显式声明:
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
type AuthState = { user: User | null; error: string | null };
const initialState: AuthState = { user: null, error: null };
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
loginSuccess(state, action: PayloadAction<User>) {
state.user = action.payload; // User
},
loginFailed(state, action: PayloadAction<string>) {
state.error = action.payload;
},
logout(state) {
state.user = null;
},
},
});
export const { loginSuccess, loginFailed, logout } = authSlice.actions;
不标注 PayloadAction<T> 时 action.payload 是 any——编译器不会提醒你,因为它以为你是有意为之。规则很简单:每个带 payload 的 reducer 都要标注,PayloadAction 里的 T 就是调用 loginSuccess(user) 时会被检查的类型。
异步用 createAsyncThunk,它的类型参数顺序是 <返回类型, 入参类型, 配置类型>:
export const fetchUser = createAsyncThunk<User, string>('user/fetch', async (id, { rejectWithValue }) => {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) return rejectWithValue('not found');
return (await res.json()) as User;
});
在 extraReducers 里,fetchUser.fulfilled 的 action.payload 会被推导为 User,rejected 的 action.payload 则是 unknown——后者常被忽略,直接当字符串用会报错,需要先判断 typeof action.payload === 'string'。
11.3.9 三套方案的选型对比
| 维度 | Context + useReducer | Zustand | Redux Toolkit |
|---|---|---|---|
| 适合的状态规模 | 主题、locale、认证态等低频变更 | 中小型全局状态 | 大型、多团队、需要强约束 |
| 类型维护成本 | 低(主要成本是 undefined 默认值) | 中(slices 与中间件要写 StateCreator) | 中高(三件套 + 类型化 hooks) |
| 重渲染粒度 | 无内置控制,value 变即全量重渲染 | selector 级订阅 | selector 级订阅 |
| 异步 | 自己写 | 自己写 | createAsyncThunk / RTK Query |
| 调试能力 | React DevTools | devtools 中间件 | Redux DevTools(时间旅行) |
| 包体积(gzip) | 0 | 约 1KB | 约 13KB |
选择标准可以归纳成一句话:变更频率低、消费方少、不需要时间旅行,就用 Context;只要出现「频繁更新 + 多处消费」,就应该换成 Zustand 或 RTK。 最常见的错误是拿 Context 当全局状态库用——每次 store 更新都重渲染整棵子树,然后用 useMemo 到处打补丁,最后既没有性能也没有可维护性。
11.3.10 与本书其它章节的衔接
本节讲的是客户端状态。服务端数据的缓存、失效与乐观更新是另一套模型,见 《TypeScript编程实战》14.1 TanStack Query 类型推导 与 《TypeScript编程实战》14.2 乐观更新与缓存失效 ——把服务端数据塞进全局 store 是另一个高频错误。状态的入口来自表单时,校验与错误映射见 《TypeScript编程实战》13.1 React Hook Form + Zod 。而组件对外的接口设计回到 《TypeScript编程实战》11.1 组件 props 与泛型组件 。
站内既有专题对状态管理做过横向对比,可作延伸阅读:React 状态管理方案 、TypeScript 类型安全的状态管理 、前端状态管理指南 。
小结
本节把共享状态拆成契约、粒度、成本三层来看。契约层:Context 的默认值优先选 undefined 并配一个会抛错的自定义 Hook,把判空收窄一次;假默认值是禁忌,它让漏包 Provider 静默失效。泛型 Context 工厂能消除重复,但返回元组必须加 as const。粒度层:Context 的 value 必须用 useMemo 固定引用,否则每次渲染全量重渲染;Zustand 与 RTK 靠 selector 控制粒度,但 selector 返回新对象会破坏引用比较,需要 useShallow;Zustand 的 create<T>()(...) 柯里化形式是中间件类型成立的前提。成本层:Zustand 的类型集中在 StateCreator<Store, [], [], Slice> 这一处,第一个参数必须是完整 Store;RTK 的 RootState / AppDispatch 一律从 store 实例反推,并配套类型化 hooks,否则 state 会是 unknown。
三个最容易犯的错:给 Context 造一个空实现的默认值,让缺失 Provider 变成静默故障;在 Zustand 的 selector 里返回对象字面量,导致每次 store 更新都重渲染;RTK 里直接 useSelector 而不用 useAppSelector,拿到 unknown 后靠 as any 绕过。它们的共同点是「编译通过、运行异常或性能异常」——类型系统能保证结构正确,但保证不了引用稳定与 Provider 存在,这两件事必须靠模式与 lint 兜住。
至此客户端的三件事——组件接口、组件内部状态、跨组件共享状态——都有了类型方案。接下来第 12 章换一个框架视角,看同一批问题在 Vue 3 里如何被回答:《TypeScript编程实战》12.1 Vue 3 组合式 API 类型
会讲 ref / reactive 的推导差异、defineProps 与 withDefaults 的编译期展开,以及 defineEmits 的类型写法——你会发现「推导从哪里断掉」这条主线在 Vue 里同样成立。
阅读导航:上一节:11.2 Hooks 类型与自定义 Hook · 下一节:12.1 Vue 3 组合式 API 类型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。