React 19 useOptimistic 实战:点赞评论先更新 UI,失败自动回滚
点赞按钮点下去,页面卡半秒才亮起来------这半秒是网络请求往返的时间。用户体验好的应用不会这样:点下去数字立刻 +1,请求在后台悄悄发,万一失败了再默默改回去。这套「先更新界面、失败再回滚」的模式叫乐观更新(optimistic update)。React 19 给了一个官方 Hook 专门做这件事:useOptimistic。这篇手把手实现一个点赞和评论列表,把它讲透。
先看没有乐观更新的「诚实但卡顿」写法
tsx
'use client';
import { useState } from 'react';
function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const [likes, setLikes] = useState(initialLikes);
const [pending, setPending] = useState(false);
async function handleLike() {
setPending(true);
// 等请求回来才更新数字 ------ 用户要盯着按钮等一整个 RTT
const res = await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
const data = await res.json();
setLikes(data.likes);
setPending(false);
}
return (
<button onClick={handleLike} disabled={pending}>
👍 {likes}
</button>
);
}
问题很明显:点击到数字变化之间,隔着一整个网络往返。网慢的时候,用户会觉得「我是不是没点上?」然后连点好几下。
手动实现乐观更新有多啰嗦
你当然可以自己用 useState 搞:点击时先把数字加上去,记住旧值,请求失败再设回去。
tsx
async function handleLike() {
const prev = likes; // 手动备份旧值
setLikes(prev + 1); // 先乐观 +1
try {
const res = await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
if (!res.ok) throw new Error('failed');
const data = await res.json();
setLikes(data.likes); // 用服务端真实值对齐
} catch {
setLikes(prev); // 失败回滚
}
}
单个按钮还行,但一旦有并发操作 (用户飞快连点、或者列表里同时有多个 pending 的 item),手动管理「哪个是乐观值、哪个是真实值、哪个已经回滚」会迅速失控。useOptimistic 就是来收拾这个复杂度的。
useOptimistic 登场
它的签名是:
tsx
const [optimisticState, addOptimistic] = useOptimistic(
actualState, // 真实状态(通常来自 props / 服务端)
(currentState, optimisticValue) => newState // 如何把乐观值合并进当前状态
);
关键在于它的自动回滚机制 :optimisticState 只在一个 async transition(通常是 Server Action 或 startTransition 包裹的异步操作)进行期间 存在。一旦这个 transition 结束,React 自动丢弃所有乐观值,optimisticState 回落到最新的 actualState。你根本不用手动备份和恢复旧值。
用它重写点赞按钮
tsx
'use client';
import { useOptimistic, useState, startTransition } from 'react';
function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const [likes, setLikes] = useState(initialLikes);
// optimisticLikes 是「叠加了乐观增量」的显示值
const [optimisticLikes, addOptimisticLike] = useOptimistic(
likes,
(current, delta: number) => current + delta // reducer:把增量加上去
);
function handleLike() {
// 乐观更新必须在 transition 里触发,否则报错
startTransition(async () => {
addOptimisticLike(1); // 立刻 +1,UI 马上变
const res = await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
const data = await res.json();
setLikes(data.likes); // 请求回来,更新真实值
// transition 结束,乐观增量被自动丢弃,optimisticLikes 回落到 likes
});
}
return (
<button onClick={handleLike}>
👍 {optimisticLikes}
</button>
);
}
注意几点:
- 显示用的是
optimisticLikes,不是likes。 addOptimisticLike(1)必须包在startTransition(或表单的 Server Action)里,否则 React 抛错「An optimistic state update occurred outside a transition」。- 请求成功后
setLikes(data.likes)更新真实值;transition 一结束,乐观增量自动清空,显示值平滑地从「乐观值」切到「服务端真实值」。 - 失败也不用你操心 :如果请求抛错、或者只是没调
setLikes,transition 结束后乐观增量照样被丢弃,数字自动回到likes的原值------这就是「自动回滚」。
更实战的场景:乐观添加评论
点赞只是加个数字,评论列表更能体现威力:提交评论时,先把这条评论以「发送中」的样子插进列表,请求成功后替换成服务端返回的真实评论(带真实 id、时间戳),失败则整条消失。
tsx
'use client';
import { useOptimistic, useState, useRef, startTransition } from 'react';
type Comment = { id: string; text: string; sending?: boolean };
function CommentBox({ postId, initial }: { postId: string; initial: Comment[] }) {
const [comments, setComments] = useState<Comment[]>(initial);
const formRef = useRef<HTMLFormElement>(null);
// reducer:把新评论追加到列表尾部,并打上 sending 标记
const [optimisticComments, addOptimisticComment] = useOptimistic(
comments,
(current, newText: string) => [
...current,
{ id: `temp-${Date.now()}`, text: newText, sending: true },
]
);
function submit(formData: FormData) {
const text = formData.get('text') as string;
if (!text.trim()) return;
startTransition(async () => {
addOptimisticComment(text); // 立刻把「发送中」的评论插进列表
formRef.current?.reset(); // 清空输入框,体验更顺
const res = await fetch(`/api/posts/${postId}/comments`, {
method: 'POST',
body: JSON.stringify({ text }),
headers: { 'Content-Type': 'application/json' },
});
const saved: Comment = await res.json();
// 用服务端真实评论更新真实列表;transition 结束后乐观项被替换
setComments((prev) => [...prev, saved]);
});
}
return (
<div>
<ul>
{optimisticComments.map((c) => (
<li key={c.id} style={{ opacity: c.sending ? 0.5 : 1 }}>
{c.text} {c.sending && <small>(发送中...)</small>}
</li>
))}
</ul>
{/* React 19 里 form action 可以直接接收 FormData */}
<form action={submit} ref={formRef}>
<input name="text" placeholder="写条评论..." />
<button type="submit">发送</button>
</form>
</div>
);
}
体验上:点「发送」的瞬间,评论就带着半透明的「发送中...」出现在列表里,输入框清空,用户可以立刻写下一条。请求回来,「发送中」样式消失换成真实评论。如果请求失败(setComments 没执行),transition 结束后那条乐观评论会自动从列表消失。
三个容易踩的坑
坑一:忘了包 startTransition
直接在事件回调里裸调 addOptimistic(...) 会报错。乐观更新的自动回滚依赖 transition 的生命周期,所以必须 在 transition 上下文里触发。用 <form action={fn}>(React 会自动包成 transition)或手动 startTransition(async () => {...})。
坑二:显示用错了 state
useOptimistic 返回两个值,UI 一定要渲染第一个 (乐观值)。如果你不小心还在渲染原来的 likes/comments,那乐观更新等于没做,界面不会立刻变。
坑三:以为它能持久化
useOptimistic 的乐观值是临时 的,只在 transition 期间存在。它不负责把数据存到服务端,也不替你更新真实 state------你仍然要在请求成功后调 setLikes / setComments 把真实数据写回去。把它理解成「transition 进行期间叠加在真实值上的一层临时视觉补丁」,transition 一结束补丁就撕掉,露出底下的真实值。
和 useActionState 搭配用更顺
在纯 Server Actions 的场景里,useOptimistic 经常和 useActionState、useFormStatus 一起用:表单提交触发 Server Action(它本身就是个 transition),乐观更新立刻反映,action 返回后真实数据由服务端 revalidate 带回来。这时候你甚至不需要手动 startTransition------<form action> 已经帮你包好了。
小结
- 乐观更新 = 先改界面、请求在后台发、失败自动回滚,消灭「点了没反应」的等待感。
useOptimistic(actualState, reducer)返回[乐观值, 触发函数],UI 渲染乐观值。- 触发函数必须在 transition 里调(
startTransition或<form action>),否则报错。 - 自动回滚是它的核心价值:乐观值只在 transition 期间存在,transition 一结束就丢弃、回落到最新真实值,你不用手动备份/恢复。
- 它不持久化数据,请求成功后仍要用
setState把服务端真实值写回真实状态。 - 点赞用「加增量」的 reducer,列表用「追加带 sending 标记的项」的 reducer,配合半透明样式体验最佳。
一句话记忆:useOptimistic 是贴在真实值上的一层临时视觉补丁,transition 结束自动撕掉------你只管贴,回滚 React 替你做。