展望2025:React状态管理该如何进行技术选型?


本文深度解读了Nadia Makarevich关于“React State Management in 2025: What You Actually Need”一文的核心思想。作者Nadia对React生态中经久不衰的“状态管理”议题提出了独到见解,并为2025年的React应用构建者描绘了一幅更务实的策略蓝图。她采取了一种“反主流”的立场,挑战了过去那种将所有状态尽数纳入Redux等单一库的传统模式,认为这种做法在许多现代场景中已显陈旧。她的核心论点在于:对“状态”进行精细化分类,并为每一类匹配最适宜的处理工具。

状态类型、挑战与推荐方案

状态类型

常见挑战 / 特性

推荐的处理方式 / 库

说明

远端状态 (Remote State)

从后端服务(如API、数据库)获取数据,涉及缓存管理、请求去重、错误处理、数据刷新、并发控制、乐观更新等复杂逻辑。

采用专门的数据获取与管理库,例如 TanStack Query (原React Query) 或 SWR

这类工具提供了成熟的数据请求、缓存、重试、失效判断及分页等全套解决方案,远比手动实现更为稳健高效。

URL状态 / 查询参数

许多UI状态(如标签页切换、分页信息、筛选条件、面板显隐)被持久化到URL查询参数中,并要求与UI进行双向同步。

当路由库本身对查询参数的支持不足时,可以借助如 nuqs 这样的库来管理。

此举可以有效规避手动编写同步逻辑时可能引入的潜在缺陷和过高复杂度。

本地状态 (Local State)

仅在单一组件内部使用的状态,如模态框的开关、表单输入值、元素的显示与隐藏等。

直接运用React内置的 useState / useReducer Hooks即可。

将这类纯粹的本地状态提升至全局管理库,是毫无必要的,只会徒增应用的复杂度与依赖。

共享状态 (Shared State)

当多个不存在直接父子关系或层级相距甚远的组件需要共同读取和修改某份状态时。

优先考虑 Prop Drilling (状态提升) 或 React Context。仅当这些原生方案无法满足需求时,才考虑引入专门的状态管理库。

作者特别提醒:当状态逻辑变得复杂或状态数量增多时,Context容易引发“Providers地狱”和不必要的组件重渲染等问题。

基于以上分类,作者阐述了她在状态管理库选型上的标准与个人偏好。她强调,只有在妥善处理了远端状态、URL状态和本地状态之后,所剩余的那一小部分真正的“共享状态”,才是状态管理库大显身手的领域。

在权衡各类共享状态方案时,她列举了以下几项关键考量因素:

  • 简洁性 (Simplicity):库的API应直观易懂,上手门槛低,不应引入过于抽象的概念或全新的编程范式。

  • 避免Provider嵌套地狱:理想的方案应至多只有一个全局Provider,甚至无需Provider。

  • 精准更新:组件若只订阅了状态的某个切片,当该切片未发生变化时,组件不应被触发更新。

  • 与React生态的契合度:应与React的最新理念(如Hooks、单向数据流)保持一致,并良好兼容SSR/Server Components等新特性。

  • 社区健康度:库的社区活跃度高,且有可靠的维护支持。

通过这些标准进行筛选,作者认为 Zustand 在绝大多数场景下都是一个极佳的选择(她个人的黄金组合是:TanStack Query + nuqs + Zustand)。但她也一再申明,这仅是其个人偏好,而非放之四海而皆准的“银弹”。

文章结尾,她给出了一个精炼的总结:

  • 停止将所有状态都丢给单一的状态管理库。

  • 将不同类型的状态解耦,并交由最适合的工具去处理:

    • 数据获取/远端状态 → TanStack Query / SWR

    • URL查询参数状态 → 专用库 (如 nuqs)

    • 本地状态 → React原生Hooks (useState / useReducer)

    • 共享状态 → 仅在Context力有不逮时,才引入状态管理库。

  • 不存在所谓的“最佳”库。 你必须根据项目的实际需求来定义自己的评判标准,并据此做出最合适的选型。


https://medium.com/@adevnadia/react-state-management-in-2025-what-you-actually-need-a138da90dbec



如果你关注我的博客已有年月,大概会发现我鲜少涉足“观点输出”型文章。我创作的内容大多聚焦于阐述事物的运作原理,这类题材留给主观看法发挥的余地本就不多。

但这次,情况有所不同!我时常收到关于状态管理的垂询,大家关心哪些库是上乘之选,以及何为最佳实践。面对这类议题,主观意见是无法回避的。

那么,当下我们该拥抱哪个库?是老牌劲旅Redux,还是后起之秀Zustand?抑或是另辟蹊径?为何不干脆只用Context呢?

所以,为了激发讨论,我先抛出我的核心论断:你可能一个都不需要!

如果你想探究其背后的缘由,请继续往下读——不过,我得先找个掩体,以防万一。😅

你为何要进行状态管理的技术选型?

所谓的最佳状态管理方案究竟为何物……但在回答这个问题之前,我们必须先反问一句:你为何想知道?

不,我是认真的。这个世界上根本不存在普适性的“最好”。一切决策都根植于具体的上下文,无一例外。(当然,冰淇淋是个特例,开心果味的无疑是顶峰。)

倘若你的动机仅仅是学习新技能以丰富履历,提升在求职市场上的竞争力,那么直接投身于最热门的那些库即可。诸如“State of React”之类的行业调查报告,会是你的绝佳向导。或者更功利一些——直接打开你心仪公司的招聘启事,搜罗其中频繁出现的库名称。

又或者,你可以选择更高阶的路径(或许是高阶中的高阶?🤔):深入研习React的核心概念与高级用法,例如其重渲染与协调(reconciliation)机制的深层原理。一旦你对这些底层知识了然于胸,所有状态管理库在你眼中将大同小异,它们不过是用略有差异的语法来诠释相同的核心理念。

如果你正在维护一个历史悠久且体量庞大的项目,并对现行的Redux状态管理方案心生不满,渴望引入更优的替代品,那么你同样无需去寻觅那个所谓的“最佳”库。在这种情境下,当务之急是精准定位当前方案究竟在哪些方面让你感到掣肘。

你最应避免的,就是用一个完全不同的范式去取代一个团队已然熟稔的工具。例如,试图用XState替换Redux来解决所谓的“Redux过于复杂”的问题。这不仅需要巨大的团队培训成本,最终还可能无法兑现你解决问题的承诺。

或许你还觉得现有的状态管理库性能欠佳,希望找到性能更卓越的替代方案。那么,在这种情况下……问题的根源真出在库本身吗?你对此有多大的把握?请用量化的数据来支撑你的论断。因为我几乎可以断言,库本身对性能的拖累微乎其微。更普遍的情形是,症结在于未经优化的重渲染逻辑,或是关键路径上存在着效率低下的计算。这些问题可能与库所倡导的编码风格有关,也可能毫无瓜葛!它甚至可能是你团队内部更深层次工程文化问题的冰山一角。无论如何,若未对问题进行彻底的诊断就草率切换技术方案,其结果大概率是白费工夫。

有趣的是,在你深入排查现有代码问题的过程中,或许会豁然开朗:根本无需一个专门的状态管理库。在“远古Redux时代”,各种类型的状态常常被不加区分地混为一谈。而今,许多这类问题早已有了针对特定场景的、更为精良的解决方案。

因此,对于你的具体用例而言,最合适的方案很可能并非用一个新库全盘替换旧库,而是根据不同状态的特性,逐步用三四个不同的小型库来取代旧库的相应部分——到那时,所谓的“状态管理”本身,几乎不再是一个值得你耗费心神的大问题。

你也可能正准备启动一个全新的项目,没有历史包袱的束缚,期望从一开始就选定最理想的技术栈,其中自然包括状态管理方案。你希望避免项目上线一年后,所选的库因 очередная “技术圈宫斗”而被淘汰,迫使团队仓促迁移到一个完全陌生的体系。若你属于这种情况,我十分欣赏你的远见卓识——接下来的内容,正是为你量身打造的。

无需状态管理库的状态问题

那么,我们究竟在管理一种称之为“state”的东西,它到底是什么?从本质上讲,它不过是一些数据。这些数据能够影响一个系统的运作方式或外在行为。举个例子,当你在品尝冰淇淋时,你大脑这个“系统”的state可以是:

  • 正满怀期待

  • 正在享受美味

  • 品尝完毕,心满意足

或者,将焦点拉回到React的语境下,state可能涵盖:

  • 一个模态框的开启/关闭状态(open/closed)

  • 从某个API获取数据的进度(无数据 / 加载中 / 加载失败 / 加载成功)

  • 一个组件的生命周期状态(已挂载 / 未挂载)

  • 以及任何其他能够影响屏幕渲染内容或UI对用户交互响应模式的数据。

谈到数据与React……

Remote State(远程状态)

在所有“状态管理”的范畴中,有一个极其特殊且通常复杂程度最高的用例,那就是处理“远程”数据。这些数据存在于应用的外部,例如数据库中,我们通常需要通过(大多数情况下的)REST接口来获取,并将其整合进React应用。

即便是一个看似最基础的“获取并渲染”的场景,其背后的复杂性也远超想象。UI至少需要应对这三种状态:

  • 数据尚未就绪

  • 数据正在加载

  • 数据加载完毕

但随后,加载过程可能遭遇波折,比如服务器发生故障。因此,你必须加入错误处理逻辑。同时,若有两个或更多组件需要从同一接口拉取数据,你显然不希望它们各自发起重复的请求。对了,如果你在一个页面获取了这份数据,很可能也期望在应用的别处复用它,这就引出了缓存的需求。与此同时,你可能还需要一套机制来定期刷新缓存,防止数据陈旧,或在必要时强制清除缓存。

谈及缓存,你肯定不希望看到“请求瀑布流”的发生吧?比如,某个数据请求被置于一个条件渲染的组件内,只有在另一个毫不相关的请求完成后才能被触发。在这种情况下,你更希望能够并行发起这些请求,提前预取那些“隐藏”的数据并置入缓存,待到真正需要时再从缓存中读取。

顺便一提,我以上所讨论的,还仅仅是“获取、渲染、然后抛诸脑后”这类简单用例!可一旦你需要在用户交互后才去请求数据呢?那么你就必须处理竞态条件以及由此衍生的额外复杂性。如果这些数据还支持修改,你将面临更大的挑战。特别是当你期望引入“乐观更新”以提升用户体验,同时又要竭力维护数据的一致性时,情况会变得异常棘手。

所以,如果你正计划从一个基于Redux的老项目进行迁移,那么你Redux代码库中极有可能有80%的内容都在与上述这些问题作斗争。在这种情况下,你需要的并非一个状态管理库。你真正需要的,是一个为React量身打造的、优秀的数据管理库。它应当能优雅地解决上述所有问题,并具备以下品质:

  • 专为React生态设计

  • 得到持续的维护

  • 拥有良好的社区口碑

  • 文档详实完备

  • 稳定可靠——至少已发布了1.x以上的正式版本,并且不会每隔半年就进行一次颠覆性的重写。

在此类场景下,我默认的首选是 TanStack Query(其前身为React Query)。我已声明本文是观点文,而非“让我们共同探讨”的研讨会!但讲真的,如果你还未曾体验过从一个基于Redux的、陈旧的自定义数据获取方案迁移到TanStack Query,务必一试。其带来的开发体验提升,足以让你为之折服,你的开发日常将焕然一新,而那原本占据80%的Redux代码将随之烟消云散。

想在组件中获取数据,并同时处理好加载中和错误状态?易如反掌:

function Component() {
  const { isPending, error, data } = useQuery({
    queryKey: ['my-data'],
    queryFn: () => fetch('https://my-url/data').then((res) => res.json()),
  });

  if (isPending) return 'Loading...';
  if (error) return 'Oops, something went wrong';

  return ... // 根据 data 渲染内容
}

想在另一个组件中从同一接口获取数据,但又不想重复发请求?你完全无需为此操心——只要使用相同的 queryKey,这个库会自动为你打理好一切。

想预取并缓存某些请求?无需改动上述代码逻辑,只需在你希望触发预取的时机调用 queryClient.prefetchQuery,剩下的工作库会全权负责。

想实现分页查询?乐观更新?基于特定条件的重试策略?这些都无需你费心,此库均已内建支持。

我很少对某个工具表现出如此强烈的热情,但这个库确实是个例外。如果你因某些缘故不喜欢TanStack Query,它还有一个强劲的竞争者——SWR。这两个库同样出色、功能强大,提供的能力也大体相当。它们的API风格略有差异:TanStack Query由独立开发者维护,而SWR则出自Vercel之手。因此,最终的抉择更多取决于你对维护方的信任度,以及你个人更偏爱哪种API设计。不妨都尝试一下,凭直觉做出决定。

与此同时,我们来聊聊另一个常见的“远程”数据存储地——它同样可以承载与你React应用相关的信息。

URL state

网页的URL,实际上也能被视为一种状态载体,不是吗?当URL变更时,UI理应随之做出响应。在现代应用中,这种响应可以千变万化:比如导航至一个新页面,或是依据查询参数(query parameters)来切换当前激活的标签页。

如今,URL的核心部分(即pathname)几乎都由外部路由库掌控,并且其结构往往与目录和文件结构挂钩(例如在Next.js中)。因此,这部分内容通常不被划入状态管理的讨论范畴。

但查询字符串(query string)部分则截然不同。它常被用来存储一些更为精细的信息,这些信息直接影响当前页面的具体行为或展示细节。看看这个URL:/somepath?search="test"&tab=1&sidebar=open&onboarding=step1。问号之后的所有内容都是状态,它们定义了当前哪个标签页被激活、侧边栏是展开还是收起,以及新手引导流程进行到了哪一步。当URL变化时,UI应同步更新;反之,当用户在引导流程中切换步骤或点击新标签页时,URL也应随之改变。

在早期的Redux应用中,你经常能看到大量为了实现URL与UI双向同步而编写的手动逻辑。而如今,部分路由库已经能自动帮你处理这项工作。以React Router为例,它提供了 useSearchParams 这个Hook,让你可以像操作state一样来使用它:

export function SomeComponent() {
  const [searchParams, setSearchParams] = useSearchParams();
  // ...
}

然而,并非所有路由库都如此“体贴”。比如Next.js,虽然它提供了便捷的Hook来读取search params,但若你想将其与组件内部的state同步——尤其涉及到更新操作——你仍需自己编写不少迂回的代码。

如果你恰好面临这种窘境,我有一条能彻底革新你开发体验的建议:请放弃手动同步的执念。 手动维系本地state与URL之间的同步,是一条布满荆棘与诡异bug的崎岖之路。不要走这条路,去拥抱 nuqs 这个库吧。

正如TanStack Query颠覆了远程状态的管理范式,这个名字略显奇特但功能强大的库,将让你的查询参数管理体验焕然一新。

使用 nuqs 将新手引导(onboarding)状态同步到URL,代码大致如下:

export function MyApp() {
  const [step, setStep] = useQueryState('onboarding');

  return (
    <>
      <button onClick={() => setStep('step2')}>Next Step</button>
    </>
  );
}

如果你想为标签页设置一个默认值,比如当URL参数不存在时默认为 1,并且希望得到的是一个真正的数字而非字符串,完全没问题:

export function MyApp() {
  const [tab, setTab] = useQueryState('tab', parseAsInteger.withDefault(1));

  return (
    <>
      <button onClick={() => setTab(2)}>Second tab</button>
    </>
  );
}

如果你曾亲手实现过上述逻辑,看到这个示例时或许已经感慨万千了。

Local State(本地状态)

在后Redux时代,还存在另一类完全无需任何状态管理库的状态类型——即 本地状态 。譬如:

  • 下拉菜单是展开还是闭合?

  • 提示框(tooltip)是否可见?

  • 组件是否已成功挂载?

这些信息都局限于组件的逻辑边界之内,无需在组件间进行传递与共享。

// isOpen 是一个局部状态,无需共享
export function CreateIssueComponent() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <button onClick={() => setIsOpen(true)}>Open Create Issue Dialog</button>
      {isOpen && <CreateIssueDialog onClose={() => setIsOpen(false)} />}
    </>
  );
}

之所以在此提及本地状态,是因为许多遗留项目——特别是那些过度依赖Redux的应用——习惯性地将所有状态都纳入全局状态库进行统一管理。但如果你是从零开始构建一个新项目,对于这类纯粹的本地需求,完全没有理由引入任何额外的库。

当然,当你需要在多个组件之间共享这类状态时,事情就开始变得微妙起来了。

Shared State(共享状态)

我们来构思一个例子。假设你的应用中有一个可折叠的左侧面板,用户可以通过多种途径来控制它的展开与收起:

  • 点击面板右边缘的悬浮按钮。

  • 拖动面板边缘来调整其宽度。

  • 点击顶部导航栏中的“展开”或“收起”图标。

  • 使用快捷键进入应用的“全屏编辑模式”(这可能通过点击按钮或快捷键触发)。

换言之,来自应用不同角落的一系列组件——它们彼此之间可能毫无关联——却都希望能够访问并修改这个侧边栏的“私有”状态。

Props Drilling

共享此类信息的一种方式是运用所谓的“状态提升”(lifting state up)技术。你需要找到所有相关组件的最近公共祖先,将状态置于该处,然后通过props将状态值和更新函数逐层传递下去。结果可想而知,场面会相当混乱:

export function MyApp() {
  const [isSidebarOpen, setIsSidebarOpen] = useState(false);

  return (
    <>
      <TopBar 
        isSidebarOpen={isSidebarOpen} 
        setIsSidebarOpen={setIsSidebarOpen} 
      />
      <div className="content">
        <Sidebar 
          isOpen={isSidebarOpen} 
          setIsOpen={setIsSidebarOpen} 
        />
        <div className="main">
          <MainArea 
            isSidebarOpen={isSidebarOpen} 
            setIsSidebarOpen={setIsSidebarOpen} 
          />
        </div>
      </div>
      <KeyboardShortcutsController 
        isSidebarOpen={isSidebarOpen} 
        setIsSidebarOpen={setIsSidebarOpen} 
      />
    </>
  );
}

此刻,侧边栏的状态弥漫在应用的各个角落。整个代码库因为充斥着这些仅用于数据传递的props而变得晦涩难懂。而且,只要你试图重构这部分状态,几乎所有相关的组件代码都得随之修改。

更糟糕的是,在这种模式下,组件层级中的每一个节点都会随着状态的变更而重渲染——并且你对此束手无策。无论你施加多少次 memo 优化,都无济于事,因为每次状态更新都会导致相关props的引用改变,从而引发组件的重新渲染。这确实非常糟糕。

顺带一提,这种模式被称为“props drilling”(属性钻探)。当然,在父子组件间偶尔进行状态共享时,这种方式是合理且可行的。但如果你需要跨越三层以上的组件层级来传递状态,或者类似的情况在整个应用中反复出现,那么这很可能是一个信号:你的架构出问题了。

Context

当你的应用饱受props drilling之苦时,Context便能大显身手。Context是React提供的一种特殊机制,用于在组件间共享数据,它允许你“跨越”组件层级,无需通过props就能将数据传递给任意深度的子组件。

其核心思想如下:

  1. 你有一些需要在多个独立组件间共享的状态。

  2. 你将这部分状态逻辑抽离到一个独立的组件中。

  3. 在该组件内部创建一个Context,并将状态存入其中。

  4. 然后将这个组件渲染在应用的顶层或相关子树的根部。

  5. 之后,应用中任何位于该组件之下的子孙组件,都可以直接访问这个Context,从而获取或更新这份共享状态。

从代码层面审视,若我们用Context来重构之前的“sidebar”示例,首先需要创建一个带有默认值的Context:

const SidebarContext = React.createContext({
  isSidebarOpen: false,
  setIsSidebarOpen: (isOpen: boolean) => {},
});

接着,创建一个用于包裹应用的“Provider”组件,它负责提供Context的值:

const SidebarProvider = ({ children }) => {
  const [isSidebarOpen, setIsSidebarOpen] = React.useState(false);

  const value = React.useMemo(
    () => ({ isSidebarOpen, setIsSidebarOpen }),
    [isSidebarOpen],
  );

  // "value" 是注入到Context中的数据
  // 所有消费此Context的组件都能访问到它
  return (
    <SidebarContext.Provider value={value}>
      {children}
    </SidebarContext.Provider>
  );
};

在原本状态所在的位置,用这个Provider进行包裹:

// SidebarProvider 包裹整个应用
// 其内部的所有组件都能访问到Context的值
export function MyApp() {
  return (
    <SidebarProvider>
      <TopBar />
      <div className="content">
        <Sidebar />
        <div className="main">
          <MainArea />
        </div>
      </div>
      <KeyboardShortcutsController />
    </SidebarProvider>
  );
}

最后,在真正需要的地方直接消费Context的值,例如在 TopBar 组件内部的一个小按钮中切换sidebar的显示状态:

const SmallToggleSidebarButton = () => {
  const { isSidebarOpen, setIsSidebarOpen } = useSidebarContext();

  return (
    <button 
      onClick={() => setIsSidebarOpen(!isSidebarOpen)}
    >
      Toggle Sidebar
    </button>
  );
};

而且,由于“切换”功能非常通用,我们甚至可以将其封装成一个预定义的API,添加到SidebarContext中,供所有消费者直接调用:

const SidebarProvider = ({ children }) => {
  const [isSidebarOpen, setIsSidebarOpen] = React.useState(false);

  const value = React.useMemo(
    () => ({
      isSidebarOpen,
      setIsSidebarOpen,
      // 添加 "toggle" API
      toggleSidebar: () => setIsSidebarOpen((prev) => !prev),
    }),
    [isSidebarOpen],
  );

  return (
    <SidebarContext.Provider value={value}>
      {children}
    </SidebarContext.Provider>
  );
};

如此一来,在按钮组件中使用这个API就显得更加优雅了:

const SmallToggleSidebarButton = () => {
  const { toggleSidebar } = useSidebarContext();

  return (
    <button onClick={() => toggleSidebar()}>
      Toggle Sidebar
    </button>
  );
};

相较于props drilling,这种方式带来了显而易见的益处:

  • 代码更加清晰,可读性显著提升,不再有那些毫无意义的props层层传递。

  • 重构状态时,无需再改动整个组件树——只需聚焦于真正消费该状态的组件即可。

  • 如果你没有直接暴露状态,而是封装了如 toggleSidebar 这样合理的API,那么未来的重构可能仅限于Provider的内部实现,消费者代码无需任何改动。

  • 代码实现了高度内聚和职责分离,这始终是软件工程的良好实践。

在性能上,它甚至可能带来提升——尽管你可能听闻Context对性能“不友好”。但实际上,只有那些真正消费该Context的组件,才会在状态变化时触发重渲染。而我们从中间层级移除掉的那些“中转站”组件,反而不会再因不相关的状态变化而渲染。

然而,Context也绝非银弹。如果你问我是否推荐在应用中大规模使用Context,我的回答是:绝对不! 原因如下:

不要用Context来管理所有共享状态

如你所见,为了让Context正常工作,我们必须引入一个“Provider”组件。这个Provider至少要被放置在所有需要访问该Context的组件的最近公共祖先之上。

// 任何需要访问Sidebar状态的组件
// 都必须被渲染在SidebarProvider的内部
export function MyApp() {
  return (
    <SidebarProvider>
      ...
    </SidebarProvider>
  );
}

这是Context的基本用法,看似无懈可击,对吧?但问题恰恰由此开始……

如果我想共享另一种状态,比如应用的主题(Theming),很自然地会再引入另一个Provider:

export function MyApp() {
  return (
    <ThemingProvider>
      <SidebarProvider>
        ...
      </SidebarProvider>
    </ThemingProvider>
  );
}

看起来依然风平浪静,不是吗?但随着你想共享的状态越来越多,麻烦也接踵而至。当你又需要共享另一份状态时,结构就演变成了这样:

export function MyApp() {
  return (
    <SomethingElse>
      <ThemingProvider>
        <SidebarProvider>
          ...
        </SidebarProvider>
      </ThemingProvider>
    </SomethingElse>
  );
}

至此,你的组件树开始陷入层层嵌套的困境,这不仅降低了代码的可读性和可维护性,也让应用结构变得愈发脆弱。每次新增一个全局状态,你都得引入一个新的Provider,并小心翼翼地将其安插在正确的位置。发展到一定阶段,你可能会尝试对某些Provider进行分组,但其他人可能无法领会你的分组意图,又在组外添加了新的Provider。有些Provider开始相互依赖,有些则根本不适合置于应用根部,于是你试图将它们下移到更靠近消费方的地方——但在下一次重构中却忘得一干二净,最终导致某些依赖Context的组件行为异常,因为它们“丢失”了上层的Provider。

这就是臭名昭著的“Provider地狱”(Providers Hell)。这种Provider模式在你只需在整个应用中共享一两个状态时非常奏效。比如,你的代码中可能只有“主题”和“用户认证状态”需要全局共享。在这种情况下,引入两个Context Provider是完全合理的。但一旦共享状态的数量超过这个范畴,局面就会迅速失控。

在这种情况下,你可能会萌生一个念头:将所有这些共享状态整合到一个Provider中,并通过逻辑结构将它们区隔开。比如这样:

const SharedStateContext = React.createContext({
  sidebar: {
    isSidebarOpen: false,
    setIsSidebarOpen: (isOpen: boolean) => {},
    toggleSidebar: () => {},
  },
  theming: {
    isDarkMode: false,
    setIsDarkMode: (isDark: boolean) => {},
    toggleDarkMode: () => {},
  },
  user: {
    // 与用户认证相关的状态
  },
  // 其他共享状态
});

const useSharedStateContext = () => React.useContext(SharedStateContext);

const SharedStateProvider = ({ children }) => {
  // 在这里实现所有的state和逻辑
  return (
    <SharedStateContext.Provider value={value}>
      {children}
    </SharedStateContext.Provider>
  );
};

然后你只需要一个Provider就能覆盖整个应用:

export function MyApp() {
  return (
    {/* 唯一的 Provider */}
    <SharedStateProvider>
      {/* 应用的其余部分 */}
    </SharedStateProvider>
  );
}

在其他组件中,你就可以通过点号来访问所需的一切:

const SmallToggleSidebarButton = () => {
  // 只解构出sidebar相关的内容
  const { sidebar } = useSharedStateContext();

  return (
    <button onClick={() => sidebar.toggleSidebar()}>
      Toggle Sidebar
    </button>
  );
};

从技术上讲,此法可行。至于它是否遵循“关注点分离”的设计原则,尚有待商榷,但我们暂且搁置此议。真正的巨大隐患在于性能——更确切地说,是由Provider内部状态变化所触发的重渲染风暴

你看,Context的一项关键局限在于:只要Context的值发生变化,所有消费这个Context的组件都会被强制重新渲染,无一幸免! 哪怕你只更新了其中一个微小的状态(比如 theming),那些只关心 sidebar 状态的组件也同样会被迫重渲染——即便它们所依赖的数据切片根本没有变化。

这会导致大量不必要的渲染,严重拖累性能,尤其是在大型应用中,问题会愈发突出。在上面的例子中,这意味着即使sidebar的状态保持不变,只要theming的状态一变,SmallToggleSidebarButton 也会重新渲染。反之亦然。对于像上面这样复杂且分布广泛的状态集合,每一次用户交互都可能引发整个应用的连锁重渲染。

如果应用规模尚小,这或许还能容忍。但你也可能遭遇侧边栏在每次交互时都卡顿数秒的窘境——用户绝对不会欣赏这种体验。当然,我们应避免过早优化,凡事应以性能测量为准。但可以肯定的是,一旦你的应用中共享状态的数量超过一两个,滥用Provider几乎必然会给你带来痛苦、bug和性能问题。

诚然,存在一些缓解措施,比如将庞大的Context拆分成更小的Provider。但说实话,到了这个地步,你其实已经在亲手“重造一个Zustand”了——那又何必不从一开始就直接选用Zustand呢?

这便自然而然地引出了我们讨论的终点——状态管理库(state management libraries)!

外部状态管理库

只有当你的应用中确实存在更为复杂的共享状态,并且你预见到Context已无法胜任时,才真正到了考虑引入外部状态管理库的时刻。

幸运的是,前文的铺垫已足够充分,我们现在对于该寻找何种方案,心中已有了清晰的蓝图。首先,通过选用一个数据管理库(如TanStack Query、SWR),我们已经消除了普通应用中约80%的状态管理难题。接着,通过使用 nuqs 来驾驭URL状态,又解决了大约10%。现在,摆在我们面前的,只剩下最后那10%的挑战了!

考虑到这一点,我在挑选共享状态解决方案时,首要看重的便是简洁性。毕竟,剩下的这10%已是精简后的核心,我不希望在此处再投入过多的心智负担。我需要一个配置极其简便、不引入全新抽象术语的方案。我期望通过阅读代码就能直观地理解其意图与行为,而无需频繁翻阅文档。

任何带有“你需要忘掉之前学的一切”暗示,或引入某种“全新的、概念化的思维模型”的工具,都会立即被我排除。这些宏大的理念若用于创造一门颠覆性的编程语言,或许尚可理解。但对于一个普通React应用中那为数不多的共享状态问题而言,实在是杀鸡用牛刀。

其次,我们始终可以利用Context在组件间共享数据。但其症结在于:Context不具备良好的可扩展性——它会带来“Provider地狱”和那些无法规避的冗余重渲染。这就意味着,一个合格的外部状态管理库必须能够解决这些痛点,否则其存在便毫无意义。它应满足以下几点:

  • 最好只有一个全局Provider,甚至完全不需要Provider

  • 应允许我们仅订阅所需的状态切片

  • 并且我们要能确信:只要该状态切片未发生变化,组件就不会被重新渲染

此外,既然我们身处React的世界,这个库就应当遵循React的哲学与工作模式。也就是说,它应该:

  • 支持最新版本的React;

  • 能在SSR(服务端渲染)和RSC(React Server Components)场景下正常工作;

  • 基于Hooks;

  • 遵循单向数据流;

  • 采用声明式的编程模型。

顺带一提:任何在其描述中包含“signals”或“observables”字眼的库,都会被我直接淘汰。因为坦白说,它们很难通过我们前面提到的“简洁性”测试 😅。需要说明的是:我并非否定“signals”和“observables”这些模式本身的价值。只是它们确实要求开发者完成一次从典型React思维到新范式的重大思维转换。而且,这些模式本身并不直观,学习曲线相当陡峭。对于那些在接触React之前没有相关背景的开发者而言,这些概念往往会带来巨大的困惑和上手难度。

回到状态管理库本身。由于这些库大多是开源项目,我希望对其未来抱有一定信心——至少在接下来的几年内能够持续存在。这意味着它应具备以下特质之一:

  • 足够流行:这样即使项目遭遇变故,也很可能会有社区成员接手维护;

  • 或者,它由几个稳定的核心维护者或某个在开源社区享有良好声誉的公司进行维护。

上述这些标准只是略微提高了成功的概率,但并不能保证万无一失。这也是为何我格外强调“简洁性”和“与React理念的契合度”。因为一旦这个库出现问题,我希望自己能以最小的代价切换到另一个相似的解决方案,而无需重写半个项目。

现在,评判标准已经明晰,我们不妨来打个分,看看结果如何?😅

根据“State of React”调查,当前最受欢迎的状态管理库大致有以下这些:

我对它们的评估如下:

简洁性 (Simplicity)

  • 👎 Redux:绝对不行。Redux因其繁琐的样板代码和陡峭的学习曲线而备受诟病。Redux Toolkit的诞生就是为了“简化Redux”,这本身就说明了问题。

  • 😐 Redux Toolkit:或许也不行。表面上看起来尚可(虽然我已久未使用),但仍然涉及过多的概念需要学习。

  • 🎉 Zustand:这部分的绝对领跑者。你只需创建一些状态,然后通过一个Hook来使用它。看两行文档你就能上手,瞬间成为专家。

  • 👎 MobX:包含“signals”、“observables”这类概念,直接淘汰。

  • 👎 Jotai:引入了“atoms”等抽象概念,不行。

  • 👎 XState:“事件驱动”、“状态机”、“actors”、“模型”、“复杂逻辑”?这绝对不属于“简单”的范畴。

Provider数量 (One or fewer providers)

  • 🎉 从目前看,所有这些库要么仅需一个Provider,要么完全无需Provider。

状态未变则不重渲染 (No re-renders if the used state part doesn’t change)

  • 🎉 Redux / Redux Toolkit:通过创建selector可以实现。

  • 🎉 Zustand:开箱即用,天然支持。

  • 其余的库大概率也以某种形式支持此特性——毕竟若完全不支持,它们也难以流行。但无论如何,这个评判标准在最终选型时依然至关重要,特别是对于较新的库,务必通过实际代码验证它是否真正能做到按需更新。

与React理念及最新特性兼容性 (Compatible with React direction and latest features)

  • 要彻底验证这一点,需要投入大量时间用这些库进行实际编码。我只会在它们满足了前面所有其他标准,并且我认真考虑将其用于下一个项目时,才会进行如此深入的验证。

  • 🤨 Redux Toolkit:我确实需要再深入研究一下,才能做出最终判断。

  • 🎉 Zustand:从我的实践经验来看,它完美支持React的各项特性。

  • 🎉 Jotai:虽然我没用过,但它由Zustand的作者和维护者开发,理应也支持所有最新特性。

  • 👎 MobX:“signals”、“observables”——这些并非声明式范式,也不符合React的风格。不行。

  • 👎 XState:“事件驱动”模式,非声明式,与React风格相悖。依然不行。

开源活跃度 (Open source concerns)

  • 🎉 这些库看起来都在积极维护之中,没有明显的危险信号。这在情理之中,因为它们都是当前最受欢迎的库之一。

所以,结论是:Zustand 在所有维度的评估中都是赢家。 这也恰好与我在新项目中的默认技术栈选择相吻合:TanStack Query + nuqs + Zustand

这是否意味着Zustand是“最好的”?绝对不是!😅 它只是完美契合了对我而言最重要的那套评估标准。换一套标准,结论可能截然不同。例如:

  • 如果我将“结构化、带有明确规范(opinionated)”作为首要考量,且不介意稍陡的学习曲线,那么 🎉 Redux Toolkit 将成为最终赢家。Zustand属于“自由发挥”型,而Redux则非常结构化、约束明确。在某些大型组织中,这种强制的一致性编码风格反而极具价值。

  • 如果我最看重的是“高级的调试体验”,🎉 Redux Toolkit 同样会胜出。其配套的Redux DevTools插件广受赞誉,调试体验异常强大。

  • 如果我极度推崇“状态机”模式,并且我的共享状态逻辑异常复杂(比如类似Figma那种交互级别),那我就会更深入地研究 🎉 XState

因此,你懂的 😊。技术选型始终是基于实际需求、项目背景和个人偏好所做出的权衡。没有“最佳”,只有“最合适”。

总结

总结一下以上所有观点:

在绝大多数情况下,尤其当你并非立志打造下一个Figma时,你根本不需要一个所谓的“状态管理库”。那个将一切都塞进Redux的时代,确实已经一去不复返了。

将你的状态按照职责进行拆分,你会发现它们各自都有比任何“通用状态管理库”更优越的解决方案:

  • 远程状态 (Remote state)
    任何源自后端、API或数据库的数据,都应交由专业的数据请求库来打理。当前的主流选择是 TanStack Query 和 SWR。它们能帮你处理好缓存、请求去重、失效、重试、分页、乐观更新等一系列棘手问题,其效果几乎注定比你手写实现要好得多。

  • URL中的状态 (Query params)
    如果你的路由库没有提供便利的方式来同步URL查询参数与本地状态,就去使用 nuqs,别再徒劳地手动同步了——那将是一段充满痛苦的经历。

  • 本地状态 (Local state)
    实际上,海量的状态根本无需共享,我们之所以有此错觉,很大程度上是过去滥用Redux遗留下的思维定势。对于这类状态,直接使用React原生的 useState 或 useReducer 就足够了。

  • 共享状态 (Shared state)
    当你确实需要在多个松散耦合的组件之间共享状态时,可以先尝试 props drilling,如果props传递层级过深,再考虑使用 Context。只有当Context也显得力不从心时,才轮到真正的状态管理库登场。

遵循这套方法,你会发现大约90%的状态管理难题都会迎刃而解。剩下的那部分状态不仅数量更少,而且结构清晰、变化可控,从而更易于理解和维护。

至于哪个是“最佳”的状态管理库……其实根本不存在所谓的“最佳”。你应该先明确对你和你的项目而言最重要的评估标准,再依据这些标准去挑选最契合的库。

以我个人为例,我选择 Zustand,因为它极其简单、维护活跃,并且与React的开发哲学高度一致。但你完全可以做出截然不同的选择——这没有任何问题。

最后

送人玫瑰,手留余香,觉得有收获的朋友可以点赞,关注一波 ,我们组建了高级前端交流群,如果您热爱技术,想一起讨论技术,交流进步,不管是面试题,工作中的问题,难点热点都可以在交流群交流,为了拿到大Offer,邀请您进群,入群就送前端精选100本电子书以及 阿里面试前端精选资料 添加 下方小助手二维码或者扫描二维码 就可以进群。让我们一起学习进步.

图片

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值