【翻译】React的新纪元已然开启:你需要了解的要点

41 阅读14分钟

原文链接:blog.logrocket.com/the-next-er…

作者:Aurora Scharff

构建异步用户界面向来困难重重。导航操作会将内容隐藏在加载图标之后,搜索框因响应结果顺序错乱而引发竞争条件,表单提交则需要手动管理每个加载状态标记和错误提示。每次异步操作都迫使你亲自动手协调流程。

这并非性能问题,而是协调问题。而React的原语现在以声明式方式解决了它。

对于开发团队而言,这标志着构建方式的根本性转变。开发者无需在每个组件中重复实现异步处理,React现已提供标准化原语自动处理协调工作。这意味着更少的错误、更一致的用户体验,以及减少调试竞争条件的耗时。

React 的异步协调原语

在React 2025大会上,React团队成员Ricky Hanlon展示了Async React Demo,展现了其强大潜力:这款具备搜索、标签页和数据变更功能的课程浏览器,在高速网络下响应如瞬息,在低速网络下运行流畅。界面更新自动协调,毫无闪烁。

这并非全新库,而是融合了React 19的协调API与React 18的并发特性。二者共同构成了React团队所称的"异步React"——通过可组合的原语构建响应式异步应用的完整系统:

  • useTransition:追踪待处理的异步任务
  • useOptimistic:在数据变更期间提供即时反馈
  • Suspense:声明式处理加载边界。
  • useDeferredValue:在快速更新中维持稳定用户体验。
  • use():使数据获取(及上下文读取)成为声明式操作。

理解这些组件的协同机制,是实现从命令式异步代码向声明式协调模式转型的关键。

问题:手动异步协调

在此之前,开发者必须手动协调每项异步操作。表单提交需要显式加载和错误状态:

function SubmitButton() {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  async function handleSubmit() {
    setIsLoading(true);
    setError(null);
    try {
      await submitToServer();
      setIsLoading(false);
    } catch (e) {
      setError(e.message);
      setIsLoading(false);
    }
  }

  return (
    <div>
      <button onClick={handleSubmit} disabled={isLoading}>
        {isLoading ? 'Submitting...' : 'Submit'}
      </button>
      {error && <div>Error: {error}</div>}
    </div>
  );
}

数据获取遵循与 useEffect 类似的命令式模式:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setIsLoading(true);
    setError(null);
    fetchUser(userId)
      .then(data => {
        setUser(data);
        setIsLoading(false);
      })
      .catch(e => {
        setError(e.message);
        setIsLoading(false);
      });
  }, [userId]);

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;

  return <div>{user.name}</div>;
}

每次异步操作都遵循这个模式:跟踪加载状态、处理错误、协调状态更新。当这个模式在数十个组件中重复时,就会导致加载状态不一致、错误处理遗漏,以及难以调试的微妙竞争条件。

基本操作

操作会自动跟踪异步任务

React 19 引入了 Actions,用于以声明式方式处理异步协调。通过 startTransition 包裹异步函数,React 能够追踪整个操作:

const [isPending, startTransition] = useTransition();

function submitAction() {
  startTransition(async () => {
    await submitToServer();
  });
}

isPending 标志在 Promise 解析前始终为 true。React 会自动处理此状态,过渡过程中抛出的错误会冒泡至错误边界,而非分散在 try/catch 块中处理(验证失败等预期错误仍需自行处理)。

React 将过渡中调用的任何函数称为“Action”。命名规范至关重要:函数名添加“Action”后缀表明其在过渡中运行(例如 submitActiondeleteAction)。

以下是使用 Actions 重写的相同按钮:

function SubmitButton() {
  const [isPending, startTransition] = useTransition();

  function submitAction() {
    startTransition(async () => {
      await submitToServer();
    });
  }

  return (
    <button onClick={submitAction} disabled={isPending}>
      {isPending ? 'Submitting...' : 'Submit'}
    </button>
  );
}

另一种方案是使用 React 19 的 <form> 组件,它能通过接受 action 属性并自动将其包裹在过渡中来处理此需求:

async function submitAction(formData) {
  await submitToServer(formData);
}

<form action={submitAction}>
  <input name="username" />
  <button>Submit</button>
</form>

错误仍会像手动操作那样向上冒泡至错误边界。当需要在 UI 中反映表单状态时,React 19 提供了表单实用工具:useFormStatus 使子组件能够访问表单的待处理状态,而 useActionState 则允许根据操作结果更新组件状态(例如显示验证错误或"点赞"计数)。

此模式同样适用于按钮、输入框和选项卡等可复用组件。设计组件可暴露 actionsubmitActionchangeAction 等操作属性,并通过内部过渡管理待处理状态及其他异步行为。我们将在后续章节深入探讨此模式。

乐观更新提供即时反馈

操作会产生待处理状态,但待处理状态未必是理想的反馈。当用户点击复选框标记任务完成时,状态应立即切换。等待服务器往返通信会中断操作流程。

useOptimistic() 在状态转换中运行,可在异步操作后台执行时实现即时更新:

function CompleteButton({ complete }) {
  const [optimisticComplete, setOptimisticComplete] = useOptimistic(complete);
  const [isPending, startTransition] = useTransition();

  function completeAction() {
    startTransition(async () => {
      setOptimisticComplete(!optimisticComplete);
      await updateCompletion(!optimisticComplete);
    });
  }

  return (
    <button 
      onClick={completeAction}
      className={isPending ? 'opacity-50' : ''}
    >
      {optimisticComplete ? <CheckIcon /> : <div></div>}
    </button>
  );
}

复选框会立即切换状态。若请求成功,服务器状态将匹配乐观更新;若失败,服务器状态保持旧值,复选框会自动恢复到原始状态。

useState(在过渡期间延迟更新)不同,useOptimistic 会立即更新。过渡边界定义了乐观状态的生命周期:它仅在异步操作处理期间存在,过渡完成后会自动"结算"至真实数据源(props或服务器状态)。

Suspend 以声明方式协调加载状态

乐观更新处理数据变动,但初始数据加载如何处理?useEffect 模式迫使我们手动管理 isLoading 状态。Suspense通过声明式定义加载边界解决了这个问题。我们可控制显示何种备用界面以及如何分段加载,从而让应用的独立部分并行加载。

Suspense 支持"启用Suspense"的数据源:异步服务器组件、通过 use() API 读取的 Promise(下文将详述),以及 TanStack Query 等提供缓存与去重功能的库(其提供 useSuspenseQuery 接口)。

以下是 Suspense 协调多个独立数据流的方式:

function App() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<ProfileSkeleton />}>
        <UserProfile />
      </Suspense>
      <Suspense fallback={<PostsSkeleton />}>
        <UserPosts />
      </Suspense>
    </div>
  );
}

每个组件都能通过独立的回退方案实现悬停效果。父组件通过 Suspense 边界处理加载状态,而非协调多个 useEffect 调用。 但存在一个问题:当触发导致组件重新加载的更新(如切换标签页或导航操作)时,加载回退内容会再次显示,遮盖已浏览的内容并产生突兀的加载状态。

结合过渡的Suspend

通过将过渡效果与 Suspend 效果结合,可以解决这个问题——它会告诉 React 保持现有内容可见,而不是立即再次显示备用内容。以下是一个适用于标签切换的示例:

function App() {
  const [tab, setTab] = useState('profile');
  const [isPending, startTransition] = useTransition();

  function handleTabChange(newTab) {
    startTransition(() => setTab(newTab));
  }

  return (
    <div>
      <nav>
        <button onClick={() => handleTabChange('profile')}>Profile</button>
        <button onClick={() => handleTabChange('posts')}>Posts</button>
      </nav>
      <Suspense fallback={<LoadingSkeleton />}>
        <div style={{ opacity: isPending ? 0.7 : 1 }}>
          {tab === 'profile' ? <UserProfile /> : <UserPosts />}
        </div>
      </Suspense>
    </div>
  );
}

现在加载回退效果仅在初始加载时显示。切换标签页时,过渡效果会保持当前内容可见,同时新数据在后台加载。不透明度样式会使其变暗,以提示正在进行更新。准备就绪后,React会原子性地替换为新内容。没有突兀的加载状态,没有卡顿。

关键在于:过渡效果会"延迟"UI更新直至异步任务完成,从而避免导航过程中Suspense边界触发回退。Next.js等框架正是利用此机制,在加载新路由时保持页面可见性。

use() 直接读取异步数据

先前我们看到 Suspense 如何与“支持 Suspense”的数据源协作。use() API 便是其中一种数据源:它为数据获取提供了替代 useEffect 的方案,允许你在渲染期间读取 Promise。

以下是将开篇的 useEffect 示例重写为 Suspense 和 use() 的版本:

function UserProfile({ userId }) {
  const user = use(fetchUser(userId));
  return <div>{user.name}</div>;
}

function App({ userId }) {
  return (
    <ErrorBoundary fallback={<div>Error loading user</div>}>
      <Suspense fallback={<div>Loading...</div>}>
        <UserProfile userId={userId} />
      </Suspense>
    </ErrorBoundary>
  );
}

该组件在读取 Promise 时暂停,触发最近的悬疑边界,待 Promise 解析后再利用数据重新渲染。错误由错误边界捕获。与 Hooks 不同,use() 支持条件调用。

需要注意的是:该 Promise 需要进行缓存。否则每次渲染时都会重新创建。实际应用中,通常会使用框架(如 Next.js)来处理缓存和去重。

延迟加载值可避免用户界面过载

Action 和 Suspend 组件处理离散操作:点击、提交、导航。但快速输入(如搜索)需要不同方案,因为你希望字段在结果加载时保持响应。

一种实现方式是设计 SearchInput 组件:通过内部乐观状态保持输入响应,并在过渡时调用 changeAction,这样父组件只需传递 valuechangeAction

若未采用设计组件,[useDeferredValue()](react.dev/reference/r…) 可实现类似的分离机制。虽然该方法可用于延迟耗费CPU的计算(提升性能),但此处的目标是确保用户体验稳定。

结合Suspense、use()错误边界,我们得以构建完整的搜索体验:

function SearchApp() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ErrorBoundary fallback={<div>Error loading results</div>}>
        <Suspense fallback={<div>Searching...</div>}>
          <div style={{ opacity: isStale ? 0.5 : 1 }}>
            <SearchResults query={deferredQuery} />
          </div>
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

function SearchResults({ query }) {
  if (!query) return <div>Start typing to search</div>;
  const results = use(fetchSearchResults(query));
  return <div>{results.map(r => <div key={r.id}>{r.name}</div>)}</div>;
}

Suspend 回退机制仅在初始加载时生效。后续搜索中,useDeferredValue 会保留旧结果可见(通过 isStale 降低透明度),同时在后台加载新结果。错误边界机制隔离故障,即使数据请求失败,搜索输入框仍可正常使用。

整合所有内容:异步 React 演示

迄今为止,我们都是单独审视每个基础组件。Async React 演示项目则展示了当框架将这些组件整合到路由、数据获取和设计系统中时会发生什么: 尝试切换网络速度,观察界面如何适应:在高速连接下即时响应,在低速连接下保持流畅。

路由器将导航包裹在过渡效果中:

function searchAction(value) {
  router.setParams("q", value);
}

更新搜索参数是异步操作,通过修改URL触发数据重新加载,同时过渡效果全程追踪所有变化。

数据层使用带缓存 promise 的 use()

function LessonList({ tab, search, completeAction }) {
  const lessons = use(data.getLessons(tab, search));
  return (
    <Design.List>
      {lessons.map(item => (
        <Lesson item={item} completeAction={completeAction} />
      ))}
    </Design.List>
  );
}

组件在数据加载期间处于悬停状态,初始加载时Suspense会显示备用方案,但在标签页切换和搜索过程中,过渡效果会使旧内容持续可见。

设计组件暴露操作属性:

<Design.SearchInput value={search} changeAction={searchAction} />

SearchInput 内部使用 useOptimistic 在过渡到新 URL 期间立即更新输入值。TabList 同样会乐观地更新选中的标签页。

命名约定("changeAction")表明传递的函数将在过渡过程中运行。

突变的作用机制相同:

async function completeAction(id) {
  await data.mutateToggle(id);
  router.refresh();
}

completeAction 通过 LessonList 传递至 Design.CompleteButton,该按钮同样暴露了一个 action 属性。在操作执行期间,按钮会乐观地更新已完成状态。

更多来自LogRocket的精彩文章:

  • 不要错过任何精彩瞬间——LogRocket精选通讯《The Replay》
  • 了解LogRocket的Galileo AI如何为您监控会话,主动呈现您应优先处理的关键事项
  • 运用React的useEffect优化应用性能
  • 多个Node版本间灵活切换
  • 探索如何在TypeScript中使用React的children属性
  • 学习通过CSS创建自定义鼠标光标
  • 顾问委员会不仅面向高管。加入LogRocket内容顾问委员会,您将参与内容方向决策,并享有专属聚会、社交认证及定制礼品等权益

以下是课程应用程序的简化示例:

export default function Home() {
  const router = useRouter();
  const search = router.search.q || "";
  const tab = router.search.tab || "all";

  function searchAction(value) {
    router.setParams("q", value);
  }

  function tabAction(value) {
    router.setParams("tab", value);
  }

  async function completeAction(id) {
    await data.mutateToggle(id);
    router.refresh();
  }

  return (
    <>
      <Design.SearchInput value={search} changeAction={searchAction} />
      <Design.TabList activeTab={tab} changeAction={tabAction}>
        <Suspense fallback={<Design.FallbackList />}>
          <LessonList
            tab={tab}
            search={search}
            completeAction={completeAction}
          />
        </Suspense>
      </Design.TabList>
    </>
  );
}

协调发生在每个层面:

  • 路由:导航包裹在过渡效果中。
  • 数据获取:数据层采用Suspense配合缓存承诺机制。
  • 组件设计:组件通过暴露"Action"属性实现内部乐观更新处理。

在高速网络环境下,更新即时生效;在低速网络环境下,乐观UI与过渡效果能自动维持响应性,无需人工编写逻辑。基础组件的复杂性由路由器、数据获取机制及设计系统共同处理,应用程序代码只需将它们串联起来即可。

构建自定义异步组件

大多数应用程序可能会直接使用已实现这些模式的库组件。但您也可以自行实现这些模式,构建自定义的异步组件。

以下是 Next.js 的实际应用示例:一个可复用的下拉菜单组件,能与 URL 参数同步。

该组件适用于筛选器、排序功能,或任何需要在 URL 中持久化保存的 UI 状态:

import { useRouter, useSearchParams } from 'next/navigation';

export function RouterSelect({ name, value, options, selectAction }) {
  const [optimisticValue, setOptimisticValue] = useOptimistic(value);
  const [isPending, startTransition] = useTransition();
  const router = useRouter();
  const searchParams = useSearchParams();

  function changeAction(e) {
    const newValue = e.target.value;
    startTransition(async () => {
      setOptimisticValue(newValue);
      await selectAction?.(newValue);

      const params = new URLSearchParams(searchParams);
      params.set(name, newValue);
      router.push(`?${params.toString()}`);
    });
  }

  return (
    <select 
      name={name}
      value={optimisticValue}
      onChange={changeAction}
      style={{ opacity: isPending ? 0.7 : 1 }}
    >
      {options.map(opt => (
        <option key={opt.value} value={opt.value}>{opt.label}</option>
      ))}
    </select>
  );
}

该组件在内部处理协调工作。父组件可通过 selectAction: 注入副作用:

function Filters() {
  const [progress, setProgress] = useState(0);
  const [optimisticProgress, incrementProgress] = useOptimistic(
    progress, 
    (prev, increment) => prev + increment
  );

  return (
    <>
      <LoadingBar progress={optimisticProgress} />
      <RouterSelect
        name="category"
        selected={selectedCategory}
        options={categoryOptions}
        selectAction={(items) => {
          incrementProgress(30);
          setProgress(100);
        }}
      />
    </>
  );
}

在此示例中,进度条的乐观更新与路由导航实现了协同配合。传递给 selectAction 的任何内容均可受益于相同的异步协调机制。命名规范("Action")表明该组件在过渡状态中运行,且可在内部调用乐观更新。

这是 Async React 演示中设计组件采用的模式。SearchInputTabListCompleteButton 均暴露 action 属性,在内部处理过渡状态、乐观更新及待处理状态。

流畅动画效果:ViewTransition(Canary版)

原始组件解决更新时机问题,而ViewTransition组件则解决视觉呈现方式。它封装了浏览器的视图过渡 API,仅在 React 过渡(由 useTransitionuseDeferredValueSuspense 触发)内部组件更新时激活。

默认情况下,它会在状态之间进行交叉淡入淡出过渡,但您可通过 CSS 自定义动画效果。

以下是 Async React 演示如何运用该组件实现课程列表动画效果:

return (
  <ViewTransition key="results" default="none" enter="auto" exit="auto">
    <Design.List>
      {lessons.map(item => (
        <ViewTransition key={item.id}>
          <Lesson item={item} completeAction={completeAction} />
        </ViewTransition>
      ))}
    </Design.List>
  </ViewTransition>
);

外部的 ViewTransitionSuspense 解析完成或状态切换时(如显示"无结果")为整个列表提供动画效果。每个条目内部的 ViewTransition 则为单个课程提供动画:搜索时,现有条目滑动至新位置,新条目淡入,已移除条目淡出。

注:ViewTransition 目前仅在React的canary版本中可用。

实际权衡

采用这些模式通常比它所替代的手动逻辑更简单。你并非在增加复杂性,而是将协调工作卸给了 React。话虽如此,以过渡、乐观更新和 Suspense 边界为思维方式确实需要转变思维模式。

当这笔投资获得回报时

这些基础组件在交互丰富的应用中表现出色:仪表盘、管理面板和搜索界面。它们能消除整类错误,竞态条件不复存在,导航体验流畅无缝。您将获得"原生应用"般的体验,同时减少冗余代码。

别修没坏的东西

如果 useStateuseEffect 已经能可靠地为你工作,就没有必要强行替换它们。如果你没有遇到竞争条件、突兀的加载状态或输入延迟等问题,就无需解决那些并不存在的问题。

迁徙路线

采用过程是渐进的。下次开发涉及复杂异步状态的功能时,尝试使用状态转换替代又一个 isLoading 标记。在需要即时反馈的场景中添加乐观UI。这些工具可与现有代码共存,因此您能按功能逐步采用。

结论:向声明式异步的转变

异步 React 通过并发渲染与协调原语的结合,构建出完整的异步任务处理体系,取代了以往需要手动编排的工作流程。

随着这些原语在生态系统中的普及,这种转变已具实践价值。在 React Conf 2025 上宣布成立的异步 React 工作组,正积极推动路由器、数据获取库及设计组件中相关模式的标准化。

我们已见证其落地实践:

  • 路由器(如Next.js)默认将导航操作封装为过渡效果;
  • 数据库(如TanStack Query和SWR)深度集成Suspense支持;
  • 设计系统预计将跟进,通过暴露操作属性来内部处理待处理状态和乐观更新。

最终,这将异步处理的复杂性从应用程序代码转移到框架层面。你描述应发生的行为(操作、变异、导航),React则协调其实现方式(待处理状态、乐观更新、加载边界)。React的下一个时代不仅关乎新特性,更在于让无缝的异步协调成为应用程序运作的默认模式。