【翻译】使用 React 19 操作构建可复用组件
![]()
使用 React 19 Actions 构建可复用的 React 组件,通过 useTransition() 和 useOptimistic() 实现功能。通过实际案例学习如何追踪待处理状态、实现乐观更新,并在 Next.js 应用路由器中暴露动作属性以实现自定义逻辑。
首发于 aurorascharff.no
React 19 Actions 简化了待处理状态、错误、乐观更新和顺序请求的处理。本文将探讨如何在 Next.js App Router 中使用 React 19 Actions 构建可复用组件。我们将利用 useTransition() 追踪过渡状态,使用 useOptimistic() 向用户提供即时反馈,并暴露 action 属性以支持父组件中的自定义逻辑。
React 19 Actions
根据更新后的 React 文档,动作(Actions)是在过渡(Transitions)内部调用的函数。过渡可以更新状态并执行副作用,相关操作将在后台执行,不会阻塞页面上的用户交互。过渡内部的所有动作都会被批量处理,组件仅在过渡完成时重新渲染一次。
Action 可用于自动处理待定状态、错误、乐观更新及顺序请求。在 React 19 表单中使用 <form action={} 属性时,以及向 useActionState() 传递函数时,也会自动创建动作。有关这些 API 的概述,请参阅我的 React 19 速查表或官方文档。
使用 useTransition() 钩子时,您还将获得一个 pending 状态,这是一个布尔值,用于指示过渡是否正在进行。这有助于在过渡过程中显示加载指示器或禁用按钮。
const [isPending, startTransition] = useTransition();
const updateNameAction = () => {
startTransition(async () => {
await updateName();
})
})
此外,在钩子版本的 startTransition() 中调用的函数抛出的错误将被捕获,并可通过错误边界进行处理。
Action函数是常规事件处理的替代方案,因此应相应地命名。否则,该函数的使用者将无法明确预期其行为类型。
用例:路由器选择组件
假设我们要构建一个可复用的下拉菜单组件,该组件会将下拉菜单选中的值设置为URL中的参数。其实现方式可能如下所示:
export interface RouterSelectProps {
name: string;
label?: string;
value?: string;
options: Array<{ value: string; label: string }>;
}
export const RouterSelect = React.forwardRef<HTMLSelectElement, RouterSelectProps>(
function Select({ name, label, value, options, ...props },
ref
) {
...
return (
<div>
{label && <label htmlFor={name}>{label}</label>}
<select
ref={ref}
id={name}
name={name}
value={value}
onChange={handleChange}
{...props}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)
}
它可能会这样处理变化:
const handleChange = async (
event: React.ChangeEvent<HTMLSelectElement>
) => {
const newValue = event.target.value;
// Update URL
const url = new URL(window.location.href);
url.searchParams.set(name, newValue);
// Simulate a delay that would occur if the route destination is doing async work
await new Promise((resolve) => setTimeout(resolve, 500));
// Navigate
router.push(url.href, { scroll: false });
};
可通过路由器传递 searchParams 来使用:
<RouterSelect
name="lang"
options={Object.entries(languages).map(([value, label]) => {
return { value, label, };
})}
label="Language"
value={searchParams.lang}
/>
由于我们使用 Next.js 应用路由器,当延迟推送到路由时,下拉框的值不会立即更新,而是等到 router.push() 完成且搜索参数更新后才会刷新。
这会导致糟糕的用户体验:用户必须等待路由推送完成才能看到下拉框的新值,可能因此产生困惑,误以为下拉框功能失效。
使用Action追踪待处理状态
让我们创建一个使用 useTransition() 钩子的 Action 来追踪推送至路由器的状态。
我们将向路由器的推送封装在返回的 startNavTransition() 函数中,该函数将追踪该转场的待处理状态。这将使我们能够知道转场的进展以及何时完成。
const [isNavPending, startNavTransition] = useTransition();
const handleChange = async (
event: React.ChangeEvent<HTMLSelectElement>
) => {
const newValue = event.target.value;
startNavTransition(async () => {
const url = new URL(window.location.href);
url.searchParams.set(name, newValue);
await new Promise((resolve) => setTimeout(resolve, 500));
router.push(url.href, { scroll: false });
});
};
现在,我们可以利用 isNavPending 状态在过渡过程中显示加载指示器,并添加 aria-busy 等辅助功能属性。
<div>
{label && <label htmlFor={name}>{label}</label>}
<select
ref={ref}
id={name}
name={name}
aria-busy={isNavPending}
value={value}
onChange={handleChange}
{...props}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{isNavPending && 'Pending nav...'}
</div>
现在,用户将收到关于其与下拉菜单交互的反馈,不会认为它无法正常工作。
然而,下拉菜单仍然无法立即更新。
使用 useOptimistic() 添加乐观更新
此时就需要用到 useOptimistic() 函数。它允许我们立即更新状态,同时仍能追踪过渡的待处理状态。我们可以在过渡内部调用它:
const [optimisticValue, setOptimisticValue] = useOptimistic(value);
const handleChange = async (
event: React.ChangeEvent<HTMLSelectElement>
) => {
const newValue = event.target.value;
startNavTransition(async () => {
setOptimisticValue(newValue);
const url = new URL(window.location.href);
url.searchParams.set(name, newValue);
await new Promise((resolve) => setTimeout(resolve, 500));
router.push(url.href, { scroll: false });
});
};
在过渡期间,optimisticValue 将作为临时客户端状态,用于立即更新下拉菜单。过渡完成后,optimisticValue 将最终更新为路由器返回的新值。
现在,我们的下拉菜单实现了即时更新,用户在过渡过程中即可看到菜单中的新值。
暴露Action属性
假设作为 RouterSelect 的用户,我们希望在选项变更时执行额外逻辑。例如,可能需要更新父组件中的其他状态或触发副作用。此时可暴露一个在选项变更时执行的函数。
参照 React 文档,我们可以向父组件暴露一个 action 属性。由于暴露的是 Action,命名时应符合规范,以便组件使用者明确预期行为。
具体实现如下:
export interface RouterSelectProps {
name: string;
label?: string;
value?: string;
options: Array<{ value: string; label: string }>;
setValueAction?: (value: string) => void;
}
我们可以在handleChange过渡中调用此属性:
const handleChange = async (
event: React.ChangeEvent<HTMLSelectElement>
) => {
const newValue = event.target.value;
startNavTransition(async () => {
setOptimisticValue(newValue);
setValueAction?.(newValue); '
const url = new URL(window.location.href);
url.searchParams.set(name, newValue);
await new Promise((resolve) => setTimeout(resolve, 500));
router.push(url.href, { scroll: false });
});
};
我们还应支持 async 函数。这使得操作回调既可以是同步的,也可以是异步的,而无需额外使用 startTransition 来包裹操作中的 await 语句。
export interface RouterSelectProps {
...// other props
setValueAction?: (value: string) => void | Promise<void>;
}
然后只需 await 操作完成,再推送到路由器:
const handleChange = async (
event: React.ChangeEvent<HTMLSelectElement>
) => {
const newValue = event.target.value;
startNavTransition(async () => {
setOptimisticValue(newValue);
await setValueAction?.(newValue);
... // Push to router
});
};
在父组件中使用 Action 属性
现在,我们可以通过 setValueAction 属性执行状态更新,并且由于命名规范,我们清楚会行为的结果。
例如,如果我们使用 useState() 设置一条消息:
const [message, setMessage] = useState('');
return (
<>
<div>
Message: {message} <br />
</div>
<RouterSelect
setValueAction={(value) => {
setMessage(`You selected ${value}`);
}}
我们知道,此状态更新将在向路由器推送完成后发生。
此外,若现在需要乐观更新,可调用 useOptimistic():
const [message, setMessage] = useState('');
const [optimisticMessage, setOptimisticMessage] = useOptimistic(message);
return (
<>
<div>
Message: {message} <br />
Optimistic message: {optimisticMessage}
</div>
<RouterSelect
setValueAction={(value) => {
setOptimisticMessage(`You selected ${value}`);
setMessage(`You selected ${value}`);
}}
我们知道此状态更新将立即发生。
最终的select实现如下所示:
'use client';
...
export interface RouterSelectProps {
name: string;
label?: string;
value?: string | string[];
options: Array<{ value: string; label: string }>;
setValueAction?: (value: string) => void | Promise<void>;
}
export const RouterSelect = React.forwardRef<HTMLSelectElement, RouterSelectProps>(
function Select(
{ name, label, value, options, setValueAction, ...props },
ref
) {
const router = useRouter();
const [isNavPending, startNavTransition] = React.useTransition();
const [optimisticValue, setOptimisticValue] = React.useOptimistic(value);
const handleChange = async (
event: React.ChangeEvent<HTMLSelectElement>
) => {
const newValue = event.target.value;
startNavTransition(async () => {
setOptimisticValue(newValue);
await setValueAction?.(newValue);
const url = new URL(window.location.href);
url.searchParams.set(name, newValue);
await new Promise((resolve) => setTimeout(resolve, 500));
router.push(url.href, { scroll: false });
});
};
return (
<div>
{label && <label htmlFor={name}>{label}</label>}
<select
ref={ref}
id={name}
name={name}
value={optimisticValue}
onChange={handleChange}
{...props}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{isNavPending && 'Pending nav...'}
</div>
);
}
);
查看这个StackBlitz,获取一个可运行的示例。
若需查看本文所述模式的更实用、更贴近实际的应用示例,请参阅我Next.js 15 Conferences项目中的Filters.tsx组件。
构建复杂、可重用的组件
在构建更复杂的可复用组件时,我们可能会遇到限制,迫使我们将乐观更新等逻辑移至父组件。
以我尝试的Ariakit示例为例,显示值的生成必须在可复用选择组件外部完成。这意味着我们无法在可复用选择组件内部调用 useOptimistic 。为解决此问题,可暴露 setValueAction 属性,然后在父组件中调用 useOptimistic() 立即更新状态。
通过这种方式,既能保持组件复用性,又允许父组件实现自定义Action逻辑。
关键要点
- 动作是在过渡中调用的函数,可更新状态并执行副作用。
-
useTransition()提供待处理状态以追踪过渡进度。 -
useOptimistic()允许在过渡中立即更新状态。 - 向可复用组件暴露动作属性,可在父组件中实现自定义逻辑。
- 在父组件中使用
useOptimistic()可立即更新状态,同时保持组件的复用性。 - 动作的命名对向组件使用者传达预期行为至关重要。
结论
在本篇博文中,我们探讨了如何利用 React 19 动作构建可复用组件,追踪过渡状态,采用乐观更新策略,并暴露动作属性以实现自定义逻辑。我们演示了 useTransition() 如何提供待处理状态以优化用户反馈,useOptimistic() 如何实现即时 UI 更新,以及暴露动作属性如何在保持组件复用性的同时允许父组件执行自定义逻辑。
通过遵循动作命名规范并运用 React 的并发特性,我们能够构建出复杂度极低却能提供流畅用户体验的组件。