前端状态管理的边界感:从组件到模块

在 React 生态中,状态管理库层出不穷(Redux, MobX, Zustand, Jotai, Recoil...)。 开发者往往陷入“选哪个库”的纠结,却忽视了更本质的问题:“这个状态属于哪里?” 混乱的状态管理通常源于边界不清,而非工具不力。本文探讨如何通过界定状态的边界,构建清晰的前端数据流。

1. 状态分类学:物以类聚

不要把所有东西都扔进 Store。首先通过生命周期和作用域对状态进行分类:

1.1 服务端状态 (Server State)

特征: 来源于 API,异步获取,存在过期时间,可能被其他用户或系统修改。

应交给 TanStack Query (React Query)SWR 管理。它们专门为服务端状态设计,提供:

// ❌ 用 Redux 管理服务端状态(不推荐)
const usersSlice = createSlice({
  name: 'users',
  initialState: { data: [], loading: false, error: null },
  reducers: {
    fetchUsersStart: (state) => { state.loading = true; },
    fetchUsersSuccess: (state, action) => {
      state.data = action.payload;
      state.loading = false;
    },
    fetchUsersError: (state, action) => {
      state.error = action.payload;
      state.loading = false;
    }
  }
});
// 需要手动处理缓存、重试、过期...

// ✅ 用 TanStack Query 管理(推荐)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

function useUsers() {
  return useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    staleTime: 5 * 60 * 1000,  // 5分钟内认为数据新鲜
    gcTime: 10 * 60 * 1000     // 10分钟后清除缓存
  });
}

function useCreateUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: createUser,
    onSuccess: () => {
      // 自动使 users 查询失效,触发重新获取
      queryClient.invalidateQueries({ queryKey: ['users'] });
    }
  });
}

1.2 URL 状态 (URL State)

特征: 需要被浏览器历史记录捕获,用户可以通过 URL 分享给他人。

筛选条件、搜索关键词、分页页码、tab 切换状态等都应该同步到 URL 参数中,而不是存在内存里。

// 使用 URLSearchParams 同步状态
import { useSearchParams } from 'react-router-dom';

function ProductList() {
  const [searchParams, setSearchParams] = useSearchParams();

  const page = parseInt(searchParams.get('page') || '1');
  const category = searchParams.get('category') || 'all';

  const handlePageChange = (newPage: number) => {
    setSearchParams({ page: newPage.toString(), category });
  };

  return (
    <>
      <CategoryFilter
        value={category}
        onChange={(cat) => setSearchParams({ page: '1', category: cat })}
      />
      <ProductGrid page={page} category={category} />
      <Pagination page={page} onChange={handlePageChange} />
    </>
  );
}

// URL: /products?page=2&category=electronics
// 用户分享这个 URL,他人打开时会看到相同的筛选结果

1.3 UI 交互状态 (Client State)

特征: 短暂、局部、不需要持久化或跨组件共享。

模态框打开/关闭、输入框的值、dropdown 展开状态等。使用 useStateuseReducer 即可。

function CommentForm() {
  const [text, setText] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleSubmit = async () => {
    setIsSubmitting(true);
    try {
      await submitComment(text);
      setText('');  // 清空输入
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <textarea value={text} onChange={(e) => setText(e.target.value)} />
      <button disabled={isSubmitting}>提交</button>
    </form>
  );
}

1.4 全局应用状态 (Global State)

特征: 跨多个页面/组件使用,需要持久化(如 localStorage),更新频率较低。

用户信息、主题设置、语言偏好、跨页面的购物车。这才是 Redux/Zustand/Jotai 真正的用武之地。

// 使用 Zustand(更轻量级的 Redux 替代方案)
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface CartStore {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
  clearCart: () => void;
}

export const useCartStore = create<CartStore>()(
  persist(
    (set) => ({
      items: [],
      addItem: (item) => set((state) => ({
        items: [...state.items, item]
      })),
      removeItem: (id) => set((state) => ({
        items: state.items.filter((item) => item.id !== id)
      })),
      clearCart: () => set({ items: [] })
    }),
    {
      name: 'cart-storage',  // localStorage key
    }
  )
);

2. 就近原则 (State Colocation)

"将状态放置在离使用它最近的地方"。 如果一个状态只被 A 组件及其子组件使用,那么它就应该定义在 A 组件中。 不要仅仅为了"方便调试"或者"未来可能用到"就将其提升到全局 Store。 状态提升(Lifting State Up)是有成本的:它扩大了重渲染范围,增加了组件间的耦合。

2.1 状态下沉的实践

// ❌ 过度提升状态
function App() {
  const [modalOpen, setModalOpen] = useState(false);  // 全局管理模态框状态
  const [selectedUser, setSelectedUser] = useState(null);

  return (
    <>
      <Header />  {/* 不使用这些状态,但会因 App 重渲染而重渲染 */}
      <Sidebar />  {/* 同上 */}
      <UserList
        onUserClick={(user) => {
          setSelectedUser(user);
          setModalOpen(true);
        }}
      />
      <UserModal
        open={modalOpen}
        user={selectedUser}
        onClose={() => setModalOpen(false)}
      />
    </>
  );
}

// ✅ 状态下沉,减少重渲染范围
function App() {
  return (
    <>
      <Header />
      <Sidebar />
      <UserListWithModal />  {/* 状态封装在内部 */}
    </>
  );
}

function UserListWithModal() {
  const [modalOpen, setModalOpen] = useState(false);
  const [selectedUser, setSelectedUser] = useState(null);

  return (
    <>
      <UserList
        onUserClick={(user) => {
          setSelectedUser(user);
          setModalOpen(true);
        }}
      />
      <UserModal
        open={modalOpen}
        user={selectedUser}
        onClose={() => setModalOpen(false)}
      />
    </>
  );
}

2.2 何时应该提升状态

只有当以下条件至少满足一个时,才考虑状态提升:

否则,保持状态在组件内部。

3. 避免 Props Drilling 的策略

层层传递 Props 确实痛苦,但 Context 不是唯一的解药,更不是高性能的解药。

3.1 组件组合 (Component Composition)

善用 children 属性。通过将子组件作为 props 传入,可以避免中间层组件感知不必要的 props。

// ❌ Props Drilling
function App() {
  const [user, setUser] = useState(null);

  return <Dashboard user={user} />;
}

function Dashboard({ user }) {
  return (
    <div>
      <Sidebar user={user} />  {/* 中间层不关心 user */}
    </div>
  );
}

function Sidebar({ user }) {
  return (
    <div>
      <UserProfile user={user} />  {/* 真正需要 user 的组件 */}
    </div>
  );
}

// ✅ 组件组合:将 UserProfile 作为 children 传入
function App() {
  const [user, setUser] = useState(null);

  return (
    <Dashboard>
      <Sidebar>
        <UserProfile user={user} />
      </Sidebar>
    </Dashboard>
  );
}

// Dashboard 和 Sidebar 不再需要知道 user 的存在
function Dashboard({ children }) {
  return <div>{children}</div>;
}

function Sidebar({ children }) {
  return <div>{children}</div>;
}

3.2 Context 拆分与优化

不要创建一个巨大的 AppContext。根据业务领域拆分为 UserContextThemeContextCartContext。 这样当 Cart 变化时,依赖 User 的组件不会无辜重渲染。

// ❌ 巨石 Context:任何值变化都会导致所有消费者重渲染
const AppContext = createContext({
  user: null,
  theme: 'light',
  cart: [],
  notifications: []
});

// ✅ 拆分 Context
const UserContext = createContext(null);
const ThemeContext = createContext('light');
const CartContext = createContext([]);

// 使用
function UserProfile() {
  const user = useContext(UserContext);  // 只订阅 user 变化
  return <div>{user.name}</div>;
}

3.3 Context Selector 模式

即使拆分了 Context,如果 Context 值是一个大对象,任何字段变化都会触发所有消费者重渲染。可以使用 Selector 模式优化:

// 使用 use-context-selector 库
import { createContext, useContextSelector } from 'use-context-selector';

const UserContext = createContext({
  name: '',
  email: '',
  age: 0,
  preferences: {}
});

function UserName() {
  // 只订阅 name 字段
  const name = useContextSelector(UserContext, (state) => state.name);
  return <div>{name}</div>;  // email 变化时不会重渲染
}

// 或者使用 Zustand 的浅比较
const useUserStore = create((set) => ({
  name: '',
  email: '',
  setName: (name) => set({ name }),
  setEmail: (email) => set({ email })
}));

function UserName() {
  // 只选择需要的字段
  const name = useUserStore((state) => state.name);
  return <div>{name}</div>;
}

4. 派生状态 (Derived State) 与单一数据源

前端最常见的 Bug 之一是"状态不同步"。这通常是因为对同一个数据维护了多份副本。

4.1 避免冗余状态

// ❌ 错误:fullName 是冗余的,如果 firstName 变了,fullName 可能忘记更新
const [firstName, setFirstName] = useState('John');
const [lastName, setLastName] = useState('Doe');
const [fullName, setFullName] = useState('John Doe');

// ❌ 错误:试图在 useEffect 中同步
useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);  // 容易忘记,且导致额外的渲染

// ✅ 正确:在渲染期间计算,无需 state
const fullName = `${firstName} ${lastName}`;

4.2 复杂派生状态的优化

对于计算成本较高的派生状态,使用 useMemo 缓存结果。但注意:不要过度优化,useMemo 本身也有开销。

function ProductList({ products, filters }) {
  // 过滤和排序是昂贵的操作
  const filteredProducts = useMemo(() => {
    return products
      .filter(p => p.category === filters.category)
      .filter(p => p.price >= filters.minPrice && p.price <= filters.maxPrice)
      .sort((a, b) => {
        if (filters.sortBy === 'price') return a.price - b.price;
        if (filters.sortBy === 'name') return a.name.localeCompare(b.name);
        return 0;
      });
  }, [products, filters]);

  return <div>{filteredProducts.map(...)}</div>;
}

4.3 Redux Selector 模式

在 Redux 中,使用 reselect 创建 Memoized Selectors,避免重复计算:

import { createSelector } from 'reselect';

// 基础 Selectors
const selectProducts = (state) => state.products;
const selectFilters = (state) => state.filters;

// 派生 Selector(自动 memoize)
const selectFilteredProducts = createSelector(
  [selectProducts, selectFilters],
  (products, filters) => {
    return products.filter(p => {
      if (filters.category && p.category !== filters.category) return false;
      if (p.price < filters.minPrice || p.price > filters.maxPrice) return false;
      return true;
    });
  }
);

// 使用
function ProductList() {
  const filteredProducts = useSelector(selectFilteredProducts);
  // 只要 products 和 filters 的引用不变,就不会重新计算
}

4.4 Single Source of Truth 原则

每个数据点只有一个权威来源。不要在多个地方存储同一份数据的副本。

// ❌ 错误:在本地复制服务端数据
function UserProfile() {
  const { data: user } = useQuery(['user', id], fetchUser);
  const [localUser, setLocalUser] = useState(user);  // 冗余!

  useEffect(() => {
    setLocalUser(user);  // 试图同步
  }, [user]);

  // ...
}

// ✅ 正确:直接使用查询返回的数据
function UserProfile() {
  const { data: user } = useQuery(['user', id], fetchUser);

  // 如果需要编辑,使用本地临时状态
  const [editedName, setEditedName] = useState('');

  const handleEdit = () => {
    setEditedName(user.name);  // 进入编辑模式时复制
  };

  const handleSave = async () => {
    await updateUser({ ...user, name: editedName });
    // TanStack Query 自动刷新数据
  };
}

5. 复杂表单状态:受控与非受控的平衡

对于极度复杂的表单(如后台配置页),完全受控(Controlled)可能会导致输入的卡顿(每次敲击都触发全量 Render)。 考虑使用 React Hook Form。它利用非受控组件(ref)和细粒度的订阅机制, 实现了高性能的表单状态管理,且与 UI 库解耦。

5.1 受控 vs 非受控组件

// 受控组件:状态由 React 管理
function ControlledInput() {
  const [value, setValue] = useState('');

  return (
    <input
      value={value}  // 受 React 状态控制
      onChange={(e) => setValue(e.target.value)}
    />
  );
}

// 非受控组件:状态由 DOM 管理
function UncontrolledInput() {
  const inputRef = useRef(null);

  const handleSubmit = () => {
    console.log(inputRef.current.value);  // 直接从 DOM 读取
  };

  return <input ref={inputRef} defaultValue="" />;
}

5.2 React Hook Form 的最佳实践

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

// 定义表单 Schema
const userSchema = z.object({
  username: z.string().min(3, '用户名至少3个字符'),
  email: z.string().email('无效的邮箱'),
  age: z.number().min(18, '必须年满18岁'),
  password: z.string().min(8, '密码至少8个字符')
});

type UserForm = z.infer<typeof userSchema>;

function UserRegistrationForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting }
  } = useForm<UserForm>({
    resolver: zodResolver(userSchema),  // 集成 Zod 校验
    defaultValues: {
      username: '',
      email: '',
      age: 18,
      password: ''
    }
  });

  const onSubmit = async (data: UserForm) => {
    await fetch('/api/register', {
      method: 'POST',
      body: JSON.stringify(data)
    });
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('username')} />
      {errors.username && <span>{errors.username.message}</span>}

      <input {...register('email')} type="email" />
      {errors.email && <span>{errors.email.message}</span>}

      <input {...register('age', { valueAsNumber: true })} type="number" />
      {errors.age && <span>{errors.age.message}</span>}

      <input {...register('password')} type="password" />
      {errors.password && <span>{errors.password.message}</span>}

      <button disabled={isSubmitting}>提交</button>
    </form>
  );
}

5.3 表单状态的性能优化

React Hook Form 的优势在于:

6. 状态管理的决策树

当面对一个新的状态时,按照以下流程决策:

1. 这个状态来自服务端吗?
   └─ 是 → 使用 TanStack Query / SWR
   └─ 否 → 继续

2. 这个状态需要反映在 URL 中吗(可分享/可收藏)?
   └─ 是 → 使用 URL Search Params
   └─ 否 → 继续

3. 这个状态需要跨多个页面共享吗?
   └─ 是 → 使用全局状态库(Zustand / Redux)
   └─ 否 → 继续

4. 这个状态需要在多个非父子组件间共享吗?
   └─ 是 → 考虑 Context 或状态提升
   └─ 否 → 使用 useState / useReducer

小结

优秀的状态管理架构,看起来应该是"分层"的。 Server State 走 Query 库,URL State 走路由,Global State 走 Zustand/Redux,Local State 走 Hooks。 有边界,才会有秩序。 这种清晰的界限感,比学会任何一个新的 State Library 都重要。

记住以下核心原则:

当你的代码库中每个状态都有明确的归属,每个更新都有清晰的数据流,你就知道架构是对的。清晰的边界带来可预测的行为,可预测的行为带来可维护的系统。