Skip to content

React 项目中 Valtio 状态管理实战指南 ​

Valtio 是一个基于 Proxy(代理) 的极简状态管理库,由 Poimandres 团队(zustand / jotai / react-three-fiber 同一团队)开发。核心理念:状态就是普通 JavaScript 对象,你只管改,它负责追踪和通知。


目录 ​

  1. 安装与初始化
  2. 创建 Store 与代理状态
  3. 在组件中读取和订阅状态(useSnapshot)
  4. 通过 Action 修改状态
  5. Computed 派生状态(getter / derive-valtio)
  6. 快照 snapshot 的使用
  7. 进阶:订阅、持久化与异步
  8. 与 useState / useReducer 的对比
  9. 注意事项与常见坑

1. 安装与初始化 ​

bash
npm install valtio
# 或
pnpm add valtio
# 或
yarn add valtio

Valtio 不需要 Provider、不需要脚手架、不需要任何初始化配置。proxy() 创建一个 store,组件里 useSnapshot() 读取,仅此而已。

tsx
// store/count.ts —— 整个"初始化"就是这一行
import { proxy } from "valtio";

export const countStore = proxy({ count: 0 });

2. 创建 Store 与代理状态 ​

2.1 基本用法 ​

ts
// 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: "南京路",
  },
});

关键特性:修改是"可变式"的,不需要展开运算符。

ts
// ❌ useState 风格:需要不可变更新
setUser((prev) => ({ ...prev, address: { ...prev.address, city: "北京" } }));

// ✅ valtio 风格:直接改嵌套属性,proxy 会自动追踪
userStore.address.city = "北京";
userStore.hobbies.push("游泳");

2.2 模块化组织多个 Store ​

ts
// 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 的桥梁。它返回一个只读快照,组件只会在它实际读取过的属性变化时重新渲染(细粒度订阅)。

tsx
// 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 细粒度渲染优化 ​

tsx
function UserProfile() {
  const snap = useSnapshot(userStore);

  // 只读取了 name —— 只有 name 变化时这个组件才会重渲染
  // userStore.age 改变不会触发本组件更新
  return <h1>{snap.name}</h1>;
}

3.2 嵌套对象的订阅 ​

tsx
function AddressCard() {
  const snap = useSnapshot(userStore);

  // 读取了 address —— address 下任何字段变化都会重渲染
  return (
    <div>
      {snap.address.city} · {snap.address.street}
    </div>
  );
}

⚠️ 只能在组件函数体内同步读取 snapshot。不要把 snap 传进异步回调再读,要读的值在渲染期就取好:

tsx
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 定义放在一起,便于维护和测试。

ts
// 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);
  },
};

在组件中调用:

tsx
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 计算属性,它会随依赖变化自动重算,快照里也能正常读取:

ts
// 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;
  },
});

组件中使用派生值与读取普通属性完全一样:

tsx
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 里写副作用。

错误示范——跨对象引用:

ts
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 直接算:

tsx
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 自动追踪"的能力,安装独立维护的包:

bash
pnpm add derive-valtio
ts
import { 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 之外获取某一时刻状态的冻结副本——比如提交表单、打日志、发请求时。

ts
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:组件外监听变化 ​

ts
import { subscribe } from "valtio";
import { userStore } from "../store/user";

// 任何变化都会触发
const unsubscribe = subscribe(userStore, () => {
  console.log("userStore 变了:", snapshot(userStore));
});

// 不再监听时
unsubscribe();

7.2 本地持久化(subscribe + localStorage) ​

ts
// 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 ​

ts
// 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;
  },
};
tsx
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 / useReducerValtio
作用范围组件内(共享需 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 可写 ​

tsx
const snap = useSnapshot(countStore);

snap.count = 10; // ❌ 无效(生产环境会报错或静默失败)
countStore.count = 10; // ✅ 正确

9.2 不要解构后还想保持响应式 ​

tsx
const { count } = useSnapshot(countStore);
// ✅ 可以:解构发生在每次渲染时,值是当前的

// ❌ 错误:缓存 store 的引用在组件外,读不到最新值
// (proxy 对象本身是可写的,但渲染依赖 snapshot)

9.3 异步回调中的 snapshot 陷阱 ​

tsx
// ❌ 异步函数里读 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(函数可以,但无法被序列化持久化)
ts
import { proxyMap } from "valtio/utils";

// Map 需要专用工具
export const tagsStore = proxyMap<string, number>([
  ["react", 10],
  ["valtio", 5],
]);

tagsStore.set("vue", 3); // ✅ 可追踪

9.5 重新赋值整个对象会替换引用 ​

ts
userStore.address = { city: "上海", street: "外滩" }; // ✅ 可以,整体替换
// 但订阅了 address 的组件会整体重渲染,
// 而 userStore.address.city = '上海' 只通知到 city 级别(视使用情况)

9.6 SSR / Next.js 注意 ​

  • Store 是模块级单例,SSR 时多个请求共享同一模块会导致状态串号
  • 解决方案:在请求级作用域创建 store(proxy() 放函数里),配合 React Context 注入
tsx
// 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>
  );
}

附:完整最小可运行示例 ​

tsx
// 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 />);