React 项目中 Valtio 状态管理实战指南
Valtio 是一个基于 Proxy(代理) 的极简状态管理库,由 Poimandres 团队(zustand / jotai / react-three-fiber 同一团队)开发。核心理念:状态就是普通 JavaScript 对象,你只管改,它负责追踪和通知。
目录
- 安装与初始化
- 创建 Store 与代理状态
- 在组件中读取和订阅状态(useSnapshot)
- 通过 Action 修改状态
- Computed 派生状态(getter / derive-valtio)
- 快照 snapshot 的使用
- 进阶:订阅、持久化与异步
- 与 useState / useReducer 的对比
- 注意事项与常见坑
1. 安装与初始化
npm install valtio
# 或
pnpm add valtio
# 或
yarn add valtioValtio 不需要 Provider、不需要脚手架、不需要任何初始化配置。proxy() 创建一个 store,组件里 useSnapshot() 读取,仅此而已。
// store/count.ts —— 整个"初始化"就是这一行
import { proxy } from "valtio";
export const countStore = proxy({ count: 0 });2. 创建 Store 与代理状态
2.1 基本用法
// store/user.ts
import { proxy } from "valtio";
interface UserState {
name: string;
age: number;
hobbies: string[];
address: {
city: string;
street: string;
};
}
export const userStore = proxy<UserState>({
name: "张三",
age: 28,
hobbies: ["篮球", "编程"],
address: {
city: "天津",
street: "南京路",
},
});关键特性:修改是"可变式"的,不需要展开运算符。
// ❌ useState 风格:需要不可变更新
setUser((prev) => ({ ...prev, address: { ...prev.address, city: "北京" } }));
// ✅ valtio 风格:直接改嵌套属性,proxy 会自动追踪
userStore.address.city = "北京";
userStore.hobbies.push("游泳");2.2 模块化组织多个 Store
// store/index.ts
import { proxy } from "valtio";
export const cartStore = proxy({ items: [] as string[], total: 0 });
export const themeStore = proxy({ mode: "light" as "light" | "dark" });
export const authStore = proxy({
token: null as string | null,
isLoggedIn: false,
});每个 store 都是独立的代理对象,组件只会订阅自己用到的那个 store。
3. 在组件中读取和订阅状态(useSnapshot)
useSnapshot(store) 是 Valtio 与 React 的桥梁。它返回一个只读快照,组件只会在它实际读取过的属性变化时重新渲染(细粒度订阅)。
// components/Counter.tsx
import { useSnapshot } from "valtio";
import { countStore } from "../store/count";
export function Counter() {
const snap = useSnapshot(countStore); // 订阅 countStore
return (
<div>
<p>当前计数:{snap.count}</p>
<button onClick={() => countStore.count++}>+1</button>
</div>
);
}3.1 细粒度渲染优化
function UserProfile() {
const snap = useSnapshot(userStore);
// 只读取了 name —— 只有 name 变化时这个组件才会重渲染
// userStore.age 改变不会触发本组件更新
return <h1>{snap.name}</h1>;
}3.2 嵌套对象的订阅
function AddressCard() {
const snap = useSnapshot(userStore);
// 读取了 address —— address 下任何字段变化都会重渲染
return (
<div>
{snap.address.city} · {snap.address.street}
</div>
);
}⚠️ 只能在组件函数体内同步读取 snapshot。不要把
snap传进异步回调再读,要读的值在渲染期就取好:
function Bad() {
const snap = useSnapshot(userStore);
const onClick = () => {
// ❌ 点击时读到的可能是过期快照
console.log(snap.name);
};
}
function Good() {
const snap = useSnapshot(userStore);
const name = snap.name; // ✅ 渲染期先取出来
const onClick = () => console.log(name);
}4. 通过 Action 修改状态
Valtio 没有 reducer、dispatch 的约束。推荐把修改逻辑收敛成 action 函数,与 store 定义放在一起,便于维护和测试。
// store/todo.ts
import { proxy } from "valtio";
interface Todo {
id: number;
text: string;
done: boolean;
}
export const todoStore = proxy({
todos: [] as Todo[],
filter: "all" as "all" | "active" | "done",
});
// ✅ actions:集中管理修改逻辑
export const todoActions = {
add(text: string) {
todoStore.todos.push({
id: Date.now(),
text,
done: false,
});
},
toggle(id: number) {
const todo = todoStore.todos.find((t) => t.id === id);
if (todo) todo.done = !todo.done;
},
remove(id: number) {
const index = todoStore.todos.findIndex((t) => t.id === id);
if (index !== -1) todoStore.todos.splice(index, 1);
},
setFilter(filter: "all" | "active" | "done") {
todoStore.filter = filter;
},
clearDone() {
todoStore.todos = todoStore.todos.filter((t) => !t.done);
},
};在组件中调用:
import { useSnapshot } from "valtio";
import { todoStore, todoActions } from "../store/todo";
function TodoApp() {
const snap = useSnapshot(todoStore);
const visibleTodos = snap.todos.filter((t) => {
if (snap.filter === "active") return !t.done;
if (snap.filter === "done") return t.done;
return true;
});
return (
<div>
<input
onKeyDown={(e) => {
if (e.key === "Enter") {
todoActions.add(e.currentTarget.value);
e.currentTarget.value = "";
}
}}
/>
<ul>
{visibleTodos.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.done}
onChange={() => todoActions.toggle(todo.id)}
/>
<span
style={{ textDecoration: todo.done ? "line-through" : "none" }}
>
{todo.text}
</span>
<button onClick={() => todoActions.remove(todo.id)}>删除</button>
</li>
))}
</ul>
</div>
);
}注意区分:读取用
snap(snapshot,只读),修改用todoStore/todoActions(proxy,可写)。在 snapshot 上赋值不会生效。
5. Computed 派生状态(getter / derive-valtio)
⚠️ 重要版本提醒:Valtio v2(含 2.3.x)中
valtio/utils已经移除了derive。旧教程里的import { derive } from "valtio/utils"会直接报not exported错误。2.3.2 的valtio/utils实际只导出:subscribeKey、watch(已标记弃用)、devtools、deepClone、proxySet、proxyMap。v2 中实现派生状态有三种推荐方式,按优先级:① 同一 proxy 上的 getter(官方推荐)→ ② 组件内纯函数计算 → ③ 独立包
derive-valtio/valtio-reactive。
5.1 方式一:proxy 上的 getter(官方推荐,零依赖)
在 store 对象上直接定义 get 计算属性,它会随依赖变化自动重算,快照里也能正常读取:
// store/shop.ts
import { proxy } from "valtio";
interface Product {
id: number;
name: string;
price: number;
qty: number;
}
export const cartStore = proxy({
products: [] as Product[],
discountRate: 0.9, // 9 折
// ✅ getter 派生:依赖同一对象的兄弟属性,通过 this 访问
get totalQty() {
return this.products.reduce((sum, p) => sum + p.qty, 0);
},
get subTotal() {
return this.products.reduce((sum, p) => sum + p.price * p.qty, 0);
},
// 派生可以依赖另一个派生(同一对象内链式计算)
get finalPrice() {
return this.subTotal * this.discountRate;
},
});组件中使用派生值与读取普通属性完全一样:
import { useSnapshot } from "valtio";
import { cartStore } from "../store/shop";
function CartFooter() {
const snap = useSnapshot(cartStore);
return (
<div>
<p>共 {snap.totalQty} 件商品</p>
<p>折前:¥{snap.subTotal.toFixed(2)}</p>
<p>折后:¥{snap.finalPrice.toFixed(2)}</p>
</div>
);
}getter 使用要点:
this指向调用它的对象(proxy 或 snapshot),只能引用同一对象的兄弟属性。- 代理上的 getter 每次访问都会重新计算(不缓存);快照上的对象 getter 值在生成快照时被拷贝,相当于缓存。
- getter 必须保持纯净,不要在 getter 里写副作用。
错误示范——跨对象引用:
const user = proxy({ name: "John" });
const greetings = proxy({
// ❌ 错误:this 指向 greetings,读不到 user.name
get greeting() {
return "Hello " + this.name;
},
});
// ✅ 正确做法:把相关 proxy 挂为属性,或跨 store 用方式三
const greetings2 = proxy({
user,
get greeting() {
return "Hello " + this.user.name;
},
});5.2 方式二:组件内纯函数计算(轻量场景)
如果计算很轻量,根本不用单独定义派生状态,渲染时基于 snap 直接算:
function CartFooter() {
const snap = useSnapshot(cartStore);
// 直接在渲染时计算,简单清晰
const totalQty = snap.products.reduce((sum, p) => sum + p.qty, 0);
return <p>共 {totalQty} 件商品</p>;
}5.3 方式三:独立包 derive-valtio(跨 store 派生、需要缓存)
如果确实需要旧版 derive 那种"跨 store 自动追踪"的能力,安装独立维护的包:
pnpm add derive-valtioimport { proxy } from "valtio";
import { derive } from "derive-valtio"; // 注意:不是 valtio/utils
export const cartStore = proxy({
products: [] as { id: number; price: number; qty: number }[],
discountRate: 0.9,
});
export const cartSummary = derive({
totalQty: (get) => get(cartStore).products.reduce((s, p) => s + p.qty, 0),
subTotal: (get) =>
get(cartStore).products.reduce((s, p) => s + p.price * p.qty, 0),
});
// 派生可以依赖另一个派生
export const finalPrice = derive({
amount: (get) => get(cartSummary).subTotal * get(cartStore).discountRate,
});
derive-valtio的重算是整对象级别的:被依赖的 proxy 上任何属性变化都会触发重算。若依赖嵌套子对象(get(store.a)),只有store.a变化才重算。另外官方已提示该包进入维护状态,更复杂的响应式需求可考虑valtio-reactive。
5.4 三种方式怎么选
| 场景 | 做法 |
|---|---|
| 派生只依赖同一 store 的字段(最常见) | 方式一:proxy getter |
计算很轻量(如 filter、toFixed) | 方式二:组件内基于 snap 直算 |
| 跨多个 store 派生 / 需要显式缓存与精细控制 | 方式三:derive-valtio |
6. 快照 snapshot 的使用
snapshot(store) 用于在 React 之外获取某一时刻状态的冻结副本——比如提交表单、打日志、发请求时。
import { snapshot } from "valtio";
import { userStore } from "../store/user";
async function submitProfile() {
// 拿到当前状态的不可变副本,之后的修改不影响它
const data = snapshot(userStore);
await fetch("/api/profile", {
method: "POST",
body: JSON.stringify({
name: data.name,
age: data.age,
city: data.address.city,
}),
});
}snapshot 与 useSnapshot 的区别:
| 对比项 | snapshot(store) | useSnapshot(store) |
|---|---|---|
| 使用位置 | 任何普通函数(事件回调、异步、Node 侧) | 只能在 React 组件 / Hook 内 |
| 是否触发重渲染 | ❌ 否 | ✅ 是 |
| 性质 | 一次性冻结副本 | 随状态更新的订阅视图 |
7. 进阶:订阅、持久化与异步
7.1 subscribe:组件外监听变化
import { subscribe } from "valtio";
import { userStore } from "../store/user";
// 任何变化都会触发
const unsubscribe = subscribe(userStore, () => {
console.log("userStore 变了:", snapshot(userStore));
});
// 不再监听时
unsubscribe();7.2 本地持久化(subscribe + localStorage)
// store/settings.ts
import { proxy, subscribe } from "valtio";
const STORAGE_KEY = "app-settings";
// 初始化时从 localStorage 恢复
const saved = localStorage.getItem(STORAGE_KEY);
export const settingsStore = proxy(
saved
? JSON.parse(saved)
: { theme: "light", fontSize: 14, language: "zh-CN" },
);
// 变化时自动写回
subscribe(settingsStore, () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot(settingsStore)));
});7.3 异步 Action
// store/auth.ts
import { proxy } from "valtio";
export const authStore = proxy({
user: null as null | { id: string; name: string },
loading: false,
error: null as string | null,
});
export const authActions = {
async login(username: string, password: string) {
authStore.loading = true;
authStore.error = null;
try {
const res = await fetch("/api/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
if (!res.ok) throw new Error("登录失败");
authStore.user = await res.json();
} catch (e) {
authStore.error = (e as Error).message;
} finally {
authStore.loading = false;
}
},
logout() {
authStore.user = null;
},
};function LoginPage() {
const snap = useSnapshot(authStore);
if (snap.loading) return <p>登录中...</p>;
return (
<div>
{snap.error && <p style={{ color: "red" }}>{snap.error}</p>}
{snap.user ? (
<p>欢迎,{snap.user.name}</p>
) : (
<button onClick={() => authActions.login("admin", "123456")}>
登录
</button>
)}
</div>
);
}8. 与 useState / useReducer 的对比
8.1 核心差异
| 维度 | useState / useReducer | Valtio |
|---|---|---|
| 作用范围 | 组件内(共享需 Context 或层层传递) | 天然全局,任意组件直接订阅 |
| 更新方式 | 不可变更新(展开、immer 等) | 直接可变赋值,Proxy 自动追踪 |
| 渲染优化 | 需手动 useMemo / useCallback / 拆 Context | 按属性访问自动细粒度订阅 |
| 学习成本 | React 内置,零成本 | 极低(会改对象就会用) |
| 时间旅行 / 严格单向数据流 | useReducer 模式清晰 | 可变式风格,需要自律 |
| 脱离 React 使用 | ❌ | ✅ store 可在组件外读写 |
8.2 什么时候选 Valtio
✅ 适合用 Valtio:
- 跨组件/跨页面共享状态——用户信息、主题、购物车、权限,不想搭 Context 金字塔
- 深层嵌套对象频繁更新——表单引擎、画布编辑器、复杂配置页,不可变更新写法太痛苦
- 需要细粒度渲染优化——大列表中只有个别项变化时,不想手写 memo
- 状态需要在组件外读写——如请求拦截器里取 token、WebSocket 回调里更新数据
- 小到中型项目想替代 Redux——不需要 Redux 那套模板代码
❌ 继续用 useState / useReducer 更好:
- 纯组件内部的临时状态(弹窗开关、输入框值)——
useState足够,杀鸡不用牛刀 - 团队严格要求单向数据流 + 可预测的状态变更审计(金融级、强合规场景)——
useReducer或 Redux Toolkit 更合适 - 需要成熟的时间旅行调试生态——Redux DevTools 更完善(Valtio 也支持 devtools,但生态较小)
8.3 常见组合方式
实际项目中两者并不冲突,常见分工是:
useState → 组件私有 UI 状态(开关、hover、输入中)
useReducer → 组件内复杂状态机(多步表单、向导流程)
valtio → 跨组件共享的业务状态(用户、购物车、全局配置)9. 注意事项与常见坑
9.1 读写分离:snap 只读,store 可写
const snap = useSnapshot(countStore);
snap.count = 10; // ❌ 无效(生产环境会报错或静默失败)
countStore.count = 10; // ✅ 正确9.2 不要解构后还想保持响应式
const { count } = useSnapshot(countStore);
// ✅ 可以:解构发生在每次渲染时,值是当前的
// ❌ 错误:缓存 store 的引用在组件外,读不到最新值
// (proxy 对象本身是可写的,但渲染依赖 snapshot)9.3 异步回调中的 snapshot 陷阱
// ❌ 异步函数里读 snap 可能拿到过期数据
const onSubmit = async () => {
await delay(1000);
console.log(snap.count); // 可能是 1 秒前的值
};
// ✅ 需要"最新值"时用 store 本身,需要"提交时刻值"用 snapshot()
const onSubmit = async () => {
await delay(1000);
console.log(countStore.count); // 最新值
console.log(snapshot(countStore)); // 当前冻结副本
};9.4 循环引用与不支持的类型
- Proxy 无法处理 Map / Set / Date 等内置对象的突变追踪(需用
proxyMap/proxySet工具,见valtio/utils) - 不要把 React 组件、DOM 节点、函数引用随意塞进 store(函数可以,但无法被序列化持久化)
import { proxyMap } from "valtio/utils";
// Map 需要专用工具
export const tagsStore = proxyMap<string, number>([
["react", 10],
["valtio", 5],
]);
tagsStore.set("vue", 3); // ✅ 可追踪9.5 重新赋值整个对象会替换引用
userStore.address = { city: "上海", street: "外滩" }; // ✅ 可以,整体替换
// 但订阅了 address 的组件会整体重渲染,
// 而 userStore.address.city = '上海' 只通知到 city 级别(视使用情况)9.6 SSR / Next.js 注意
- Store 是模块级单例,SSR 时多个请求共享同一模块会导致状态串号
- 解决方案:在请求级作用域创建 store(
proxy()放函数里),配合 React Context 注入
// app-router 下的简单做法:每个客户端组件树注入独立 store
import { createContext, useContext, useRef } from "react";
import { proxy } from "valtio";
const StoreContext = createContext<ReturnType<typeof createStore> | null>(null);
function createStore() {
return proxy({ count: 0 });
}
export function StoreProvider({ children }: { children: React.ReactNode }) {
const storeRef = useRef(createStore()); // 每次挂载创建新实例
return (
<StoreContext.Provider value={storeRef.current}>
{children}
</StoreContext.Provider>
);
}附:完整最小可运行示例
// main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { proxy, useSnapshot, snapshot } from "valtio";
// ---------- Store(派生用 getter,无需 derive)----------
const store = proxy({
firstName: "三",
lastName: "张",
count: 0,
get fullName() {
return `${this.lastName}${this.firstName}`;
},
get doubleCount() {
return this.count * 2;
},
});
// ---------- Actions ----------
const actions = {
increment: () => store.count++,
setFirstName: (v: string) => (store.firstName = v),
setLastName: (v: string) => (store.lastName = v),
logState: () => console.log("当前快照:", snapshot(store)),
};
// ---------- 组件 ----------
function App() {
const snap = useSnapshot(store);
return (
<div style={{ padding: 24, fontFamily: "sans-serif" }}>
<h1>你好,{snap.fullName}</h1>
<p>
计数:{snap.count}(双倍:{snap.doubleCount})
</p>
<button onClick={actions.increment}>+1</button>
<button onClick={actions.logState} style={{ marginLeft: 8 }}>
打印快照
</button>
<div style={{ marginTop: 16 }}>
<input
value={snap.lastName}
onChange={(e) => actions.setLastName(e.target.value)}
placeholder="姓"
/>
<input
value={snap.firstName}
onChange={(e) => actions.setFirstName(e.target.value)}
placeholder="名"
style={{ marginLeft: 8 }}
/>
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);