原文链接:www.columkelly.com/blog/use-op…
作者:Colum Kelly
乐观式UI
在后台完成操作的同时,立即响应用户交互更新界面。这种设计将界面响应能力与网络延迟解耦。典型的例子就是"点赞"按钮。
你可能会以为在React中实现这个功能很简单,但要避免引入视觉故障或竞争条件却并非易事。React 19的useOptimistic钩子似乎使该模式成为核心功能。但我认为,随着并发React的出现,乐观UI反而变得更加复杂且难以实现。
让我们探讨手动乐观更新为何历来脆弱、useOptimistic如何提供帮助,以及它为何并非万能解药。
我们从何而来
要理解useOptimistic的架构意义,我们首先需要审视先前用户空间实现方案的局限性。
示例1:不良的乐观更新
最原始的方法是在用户交互时更新UI,并在每次服务器响应时再次更新。这种做法存在若干问题,且可能导致UI出现多种不同步情况。codesandbox.io/s/r4qgdq?fi…
import { useState } from 'react';
import { toggleLike } from './api';
import { Heart } from 'lucide-react';
import { NetworkPanel } from './NetworkPanel';
import './styles.css';
export default function App() {
const [liked, setLiked] = useState(false);
async function handleToggle() {
const nextLiked = !liked;
// 1. Optimistic Update
// We immediately flip the state locally
setLiked(nextLiked);
try {
// 2. Server Request
// Wait for the server to complete the operation
const updated = await toggleLike(nextLiked);
// 3. Sync
// Overwrite local state with server's state
setLiked(updated); // Try commenting this line out
} catch (_) {
// Revert on error - Causes sync issues
setLiked(!nextLiked);
}
}
return (
<div className="app-container">
<div className="card">
<button
onClick={handleToggle}
className={'like-button ' + (liked ? 'liked' : '')}
>
<Heart fill={liked ? "currentColor" : "none"} size={48} />
</button>
</div>
<NetworkPanel />
</div>
);
}
如果我们疯狂点击按钮,随着请求完成,状态会来回闪烁。如果我们随机化延迟,就会出现竞争条件,最终状态变得难以预测。
注释掉第24行略有改善。这阻止了与服务器的最终同步,解决了闪烁问题,但又引发了新问题。如果UI失去同步,它将无法恢复,除非出现另一个错误或重新加载页面。
在该行仍被注释的情况下,切换"始终报错"并发起两组请求。界面将从后一个乐观状态回退至前一个乐观状态,且无法与服务器状态达成一致。
示例 2. 更优的乐观更新方案
为避免上述问题,我们需要维护两个独立状态:服务器状态和乐观状态。通过引用机制手动同步两者,追踪最新请求 ID 并丢弃过期响应。
该方案可行,但需编写大量冗余代码才能正确处理竞争条件。codesandbox.io/s/nvxsvf?fi…
import { useState, useRef } from 'react';
import { toggleLike } from './api';
import { Heart } from 'lucide-react';
import { NetworkPanel } from './NetworkPanel';
import './styles.css';
export default function App() {
const [serverLiked, setServerLiked] = useState(false);
const [optimisticLiked, setOptimisticLiked] = useState(false);
const callIdRef = useRef(null);
async function handleToggle() {
const nextLiked = !optimisticLiked;
// 1. Optimistic Update
setOptimisticLiked(nextLiked);
const callId = Math.random().toString(36);
callIdRef.current = callId;
try {
// 2. Server Request
const updated = await toggleLike(nextLiked);
// 3. Sync (Only if last call)
if (callIdRef.current === callId) {
setServerLiked(updated);
setOptimisticLiked(updated);
}
} catch (_) {
// Revert
if (callIdRef.current === callId) {
setOptimisticLiked(serverLiked);
}
}
}
return (
<div className="app-container">
<div className="card">
<button
onClick={handleToggle}
className={'like-button ' + (optimisticLiked ? 'liked' : '')}
>
<Heart fill={optimisticLiked ? "currentColor" : "none"} size={48} />
</button>
</div>
<NetworkPanel />
</div>
);
}
虽然这种方法解决了前一个示例中的问题,但仍然存在缺陷:
冗余代码与复杂性
我们现在需要管理两个独立状态:一个用于追踪请求ID的引用,以及事件处理程序中复杂的命令式逻辑。必须手动确保在成功和失败两种情况下都检查callId。
另一种方案是使用中止控制器,在新请求发起时取消所有进行中的请求,但这将产生类似数量的代码。
过渡状态
当更新发生在过渡状态时会怎样?Concurrent React 使用过渡状态实现非阻塞更新。在下例中,待办事项通过表单操作更新,React 将此操作视为过渡状态处理。
示例 3. 过渡状态内的乐观更新
import { useState } from 'react';
import { TodoList } from './TodoList';
import { updateTodo } from './api';
import './styles.css';
const initialTodos = {
1: { id: 1, title: "Walk the dog", completed: false },
};
export default function App() {
const [todos, setTodos] = useState(initialTodos);
async function toggleTodo(todo) {
// 1. Optimistic Update
// Now this doesn't work because we're in a transition
setTodos((prev) => ({ ...prev, [todo.id]: todo }));
try {
// 2. Server Request
const updated = await updateTodo(todo.id, todo);
// 3. Sync
// This is the only update that we'll see
setTodos((prev) => ({ ...prev, [todo.id]: updated }));
} catch (e) {
// Revert on error
setTodos((prev) => ({
...prev,
[todo.id]: { ...todo, completed: !todo.completed },
}));
}
}
return <TodoList todos={todos} toggleTodoAction={toggleTodo} />;
}
import { Check } from 'lucide-react';
import { NetworkPanel } from './NetworkPanel';
export function Todo({ todo, action }) {
async function toggleAction() {
await action({ ...todo, completed: !todo.completed });
}
return (
<form action={toggleAction} className="todo-item">
<span className="todo-title">{todo.title}</span>
<button
type="submit"
className={`toggle-btn ${todo.completed && "checked"}`}
>
{todo.completed && <Check size={16} />}
</button>
</form>
);
}
export function TodoList({ todos, toggleTodoAction }) {
return (
<div className="app-container">
<div className="card">
<h3>Todos</h3>
<div className="todo-list">
{Object.values(todos).map((todo) => (
<Todo key={todo.id} todo={todo} action={toggleTodoAction} />
))}
</div>
</div>
<NetworkPanel />
</div>
);
}
这行不通。当状态更新函数在过渡过程中被调用时,并不会立即触发重新渲染,而我们需要立即显示乐观状态。此时就需要用到 useOptimistic。它允许我们在过渡过程中立即更新 UI,同时将状态回滚操作批量处理至最后一次过渡结束时执行。
示例 4. useOptimistic
import { useState, useOptimistic, startTransition } from "react";
import { updateTodo } from './api';
import { TodoList } from './TodoList';
import './styles.css';
const initialTodos = {
1: { id: '1', title: 'Walk the dog', completed: false },
};
export default function App() {
const [todos, setTodos] = useState(initialTodos);
async function toggleTodo(todo) {
try {
// 1. Server Request
const updated = await updateTodo(todo.id, todo);
// 2. Update state in another transition
startTransition(() => {
setTodos((prev) => ({ ...prev, [todo.id]: updated }));
});
} catch(_) {
// No need to revert on error, this happens automatically
}
}
return (
<TodoList todos={todos} toggleTodoAction={toggleTodo} />
);
}
import { Check } from 'lucide-react';
import { NetworkPanel } from './NetworkPanel';
import { useOptimistic } from 'react';
export function Todo({ todo, action }) {
const [checked, setChecked] = useOptimistic(todo.completed);
async function toggleAction() {
setChecked(!checked);
await action({ ...todo, completed: !checked });
}
return (
<form action={toggleAction} className="todo-item">
<span className="todo-title">{todo.title}</span>
<button
type="submit"
className={`toggle-btn ${checked ? "checked" : ""}`}
>
{checked && <Check size={16} />}
</button>
</form>
);
}
export function TodoList({ todos, toggleTodoAction }) {
return (
<div className="app-container">
<div className="card">
<h3>Todos</h3>
<div className="todo-list">
{Object.values(todos).map((todo) => (
<Todo key={todo.id} todo={todo} action={toggleTodoAction} />
))}
</div>
</div>
<NetworkPanel />
</div>
);
}
看起来可行。我们移除了部分setTodos调用,并将剩余调用封装在过渡中。同时在Todo组件中新增了一个状态变量来追踪乐观状态。
相比之前使用callIds ref的方案,这更简洁但稍显隐晦。另一个问题是过渡机制无法避免竞争条件——通过随机延迟连续点击复选框,仍可能导致UI失调。React文档对此有说明:
这是因为更新是异步调度的,React在跨异步边界时会丢失操作顺序的上下文。
好吧,我们再次未能处理竞争条件。还有更多:
这是预期的行为,因为过渡中的动作不保证执行顺序。对于常见用例,React提供了更高阶的抽象层,如
useActionState和<form>动作,它们会为你处理顺序问题。对于高级用例,你需要自行实现队列和中止逻辑来处理此问题。
换言之,"我们对此也有对应的API"。
示例5. 纯粹炫技
最后这个示例结合使用了useActionState和useOptimistic。我原本写了些重复代码,但Ricky指出了问题并协助重构。它确实解决了竞争条件问题,最终实现相当精简。
猜猜React如何确保执行顺序正确,然后亲自尝试实现。
import { useState, useOptimistic, useActionState, startTransition } from "react";
import { TodoList } from "./TodoList";
import { updateTodo } from "./api";
import "./styles.css";
const initialTodos = {
1: { id: "1", title: "Walk the dog", completed: false },
};
export default function App() {
// Wrap the todos in action state - think of this as an async state updater
const [todos, toggleTodoAction] = useActionState(async (todos, newTodo) => {
try {
// 1. Server request
const updated = await updateTodo(newTodo.id, newTodo);
// 2. Return the new state
return { ...todos, [updated.id]: updated };
} catch (_) {
// Return the previous state (or an error state)
return todos;
}
}, initialTodos);
return <TodoList todos={todos} toggleTodoAction={toggleTodoAction} />;
}
import { Check } from "lucide-react";
import { useOptimistic } from "react";
import { NetworkPanel } from "./NetworkPanel";
function Todo({ todo, action }) {
// Wrap an existing value in an optimisitic value
const [checked, setChecked] = useOptimistic(todo.completed);
async function toggleAction() {
// Set optimistic state
setChecked(!checked);
// Perform action
await action({ ...todo, completed: !checked });
}
return (
<form key={todo.id} action={toggleAction} className="todo-item">
<span className="todo-title">{todo.title}</span>
<button
type="submit"
className={`toggle-btn ${checked && "checked"}`}
>
{checked && <Check size={16} />}
</button>
</form>
);
}
export function TodoList({ todos, toggleTodoAction }) {
return (
<div className="app-container">
<div className="card">
<h3>Todos</h3>
<div className="todo-list">
{Object.values(todos).map((todo) => (
<Todo key={todo.id} todo={todo} action={toggleTodoAction} />
))}
</div>
</div>
<NetworkPanel />
</div>
);
}
请求会被排队并顺序处理,每次仅执行一个请求。现在我们需要处理错误、竞争条件,并在过渡期间更新用户界面。
那么我们该如何应对?
事实上,这些新API并未让上述情况比以往更易处理。useOptimistic虽有帮助,但它既未简化乐观UI的实现,也无法独立解决竞争条件问题。
要正确使用它,你必须真正理解过渡、动作以及 useTransition 和 useActionsState 这样的钩子。
之所以想写这篇文章,是因为我自己也花了大量时间才弄明白这些概念。你绝对想不到 Medium 上有多少关于 useOptimistic 的文章,竟然连过渡机制都不提。
坦白说,这些API应该交给库和框架作者来处理。我建议你看看Ricky Hanlon在React大会上的演讲异步React。在一段充满失误的演示后,Ricky坦言:
"所以关键点在于——你们也看到我挣扎了——老实说,编写这些独立功能实在令人头疼。"
问题正是如此。React团队曾因建议搭配框架使用引发争议,但原因就在于此。这些新API不仅令人头疼,既未能减少冗余代码量,也无法降低引入错误的概率。
其初衷是让框架作者利用这些API构建路由器和数据层,使我们能享受其优势,而无需亲自应对复杂性。