原文链接:blog.logrocket.com/the-next-er…
构建异步用户界面向来困难重重。导航操作会将内容隐藏在加载图标之后,搜索框因响应结果顺序错乱而引发竞争条件,表单提交则需要手动管理每个加载状态标记和错误提示。每次异步操作都迫使你亲自动手协调流程。
这并非性能问题,而是协调问题。而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”后缀表明其在过渡中运行(例如 submitAction、deleteAction)。
以下是使用 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 则允许根据操作结果更新组件状态(例如显示验证错误或"点赞"计数)。
此模式同样适用于按钮、输入框和选项卡等可复用组件。设计组件可暴露 action、submitAction 或 changeAction 等操作属性,并通过内部过渡管理待处理状态及其他异步行为。我们将在后续章节深入探讨此模式。
乐观更新提供即时反馈
操作会产生待处理状态,但待处理状态未必是理想的反馈。当用户点击复选框标记任务完成时,状态应立即切换。等待服务器往返通信会中断操作流程。
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,这样父组件只需传递 value 和 changeAction。
若未采用设计组件,[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 演示中设计组件采用的模式。SearchInput、TabList 和 CompleteButton 均暴露 action 属性,在内部处理过渡状态、乐观更新及待处理状态。
流畅动画效果:ViewTransition(Canary版)
原始组件解决更新时机问题,而ViewTransition组件则解决视觉呈现方式。它封装了浏览器的视图过渡 API,仅在 React 过渡(由 useTransition、useDeferredValue 或 Suspense 触发)内部组件更新时激活。
默认情况下,它会在状态之间进行交叉淡入淡出过渡,但您可通过 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>
);
外部的 ViewTransition 在 Suspense 解析完成或状态切换时(如显示"无结果")为整个列表提供动画效果。每个条目内部的 ViewTransition 则为单个课程提供动画:搜索时,现有条目滑动至新位置,新条目淡入,已移除条目淡出。
注:ViewTransition 目前仅在React的canary版本中可用。
实际权衡
采用这些模式通常比它所替代的手动逻辑更简单。你并非在增加复杂性,而是将协调工作卸给了 React。话虽如此,以过渡、乐观更新和 Suspense 边界为思维方式确实需要转变思维模式。
当这笔投资获得回报时
这些基础组件在交互丰富的应用中表现出色:仪表盘、管理面板和搜索界面。它们能消除整类错误,竞态条件不复存在,导航体验流畅无缝。您将获得"原生应用"般的体验,同时减少冗余代码。
别修没坏的东西
如果 useState 和 useEffect 已经能可靠地为你工作,就没有必要强行替换它们。如果你没有遇到竞争条件、突兀的加载状态或输入延迟等问题,就无需解决那些并不存在的问题。
迁徙路线
采用过程是渐进的。下次开发涉及复杂异步状态的功能时,尝试使用状态转换替代又一个 isLoading 标记。在需要即时反馈的场景中添加乐观UI。这些工具可与现有代码共存,因此您能按功能逐步采用。
结论:向声明式异步的转变
异步 React 通过并发渲染与协调原语的结合,构建出完整的异步任务处理体系,取代了以往需要手动编排的工作流程。
随着这些原语在生态系统中的普及,这种转变已具实践价值。在 React Conf 2025 上宣布成立的异步 React 工作组,正积极推动路由器、数据获取库及设计组件中相关模式的标准化。
我们已见证其落地实践:
- 路由器(如Next.js)默认将导航操作封装为过渡效果;
- 数据库(如TanStack Query和SWR)深度集成Suspense支持;
- 设计系统预计将跟进,通过暴露操作属性来内部处理待处理状态和乐观更新。
最终,这将异步处理的复杂性从应用程序代码转移到框架层面。你描述应发生的行为(操作、变异、导航),React则协调其实现方式(待处理状态、乐观更新、加载边界)。React的下一个时代不仅关乎新特性,更在于让无缝的异步协调成为应用程序运作的默认模式。