Remix乐观更新 #
一、乐观更新概述 #
乐观更新是一种用户体验优化技术,在服务器响应之前先更新UI,让用户感觉操作立即生效。如果服务器返回失败,则回滚到之前的状态。
二、基本乐观更新 #
2.1 使用useOptimistic #
Remix 提供了 useOptimistic Hook 来实现乐观更新:
tsx
import { useOptimistic } from "@remix-run/react";
import { Form } from "@remix-run/react";
interface Message {
id: string;
content: string;
sending?: boolean;
}
export default function Messages({ messages }: { messages: Message[] }) {
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages,
(state, newMessage: string) => [
...state,
{ id: "temp", content: newMessage, sending: true },
]
);
return (
<div>
<ul>
{optimisticMessages.map((message) => (
<li key={message.id} className={message.sending ? "opacity-50" : ""}>
{message.content}
{message.sending && <span> (发送中...)</span>}
</li>
))}
</ul>
<Form
method="post"
onSubmit={(e) => {
const form = e.currentTarget;
const content = new FormData(form).get("content");
if (typeof content === "string") {
addOptimisticMessage(content);
}
}}
>
<input name="content" required />
<button type="submit">发送</button>
</Form>
</div>
);
}
2.2 完整示例 #
tsx
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { useLoaderData, useOptimistic, Form } from "@remix-run/react";
interface Todo {
id: string;
title: string;
completed: boolean;
}
export async function loader({}: LoaderFunctionArgs) {
const todos = await getTodos();
return json({ todos });
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "create") {
const title = formData.get("title") as string;
await createTodo(title);
return redirect("/todos");
}
if (intent === "toggle") {
const id = formData.get("id") as string;
await toggleTodo(id);
return json({ success: true });
}
return json({ error: "未知操作" }, { status: 400 });
}
export default function Todos() {
const { todos } = useLoaderData<typeof loader>();
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo: { title: string }) => [
...state,
{ id: "temp", title: newTodo.title, completed: false, pending: true },
]
);
const [toggledTodos, toggleOptimisticTodo] = useOptimistic(
optimisticTodos,
(state, id: string) =>
state.map((todo) =>
todo.id === id
? { ...todo, completed: !todo.completed, pending: true }
: todo
)
);
return (
<div>
<ul>
{toggledTodos.map((todo) => (
<li
key={todo.id}
className={todo.pending ? "opacity-50" : ""}
>
<Form method="post">
<input type="hidden" name="intent" value="toggle" />
<input type="hidden" name="id" value={todo.id} />
<button type="submit">
{todo.completed ? "✓" : "○"}
</button>
<span>{todo.title}</span>
</Form>
</li>
))}
</ul>
<Form
method="post"
onSubmit={(e) => {
const form = e.currentTarget;
const title = new FormData(form).get("title");
if (typeof title === "string" && title.trim()) {
addOptimisticTodo({ title: title.trim() });
}
}}
>
<input type="hidden" name="intent" value="create" />
<input name="title" placeholder="添加待办事项" required />
<button type="submit">添加</button>
</Form>
</div>
);
}
三、点赞功能 #
3.1 乐观点赞 #
tsx
import { useFetcher, useOptimistic } from "@remix-run/react";
interface LikeState {
liked: boolean;
count: number;
}
export default function LikeButton({
postId,
initialLiked,
initialCount
}: {
postId: string;
initialLiked: boolean;
initialCount: number;
}) {
const fetcher = useFetcher();
const [optimisticLike, toggleLike] = useOptimistic(
{ liked: initialLiked, count: initialCount },
(state): LikeState => ({
liked: !state.liked,
count: state.liked ? state.count - 1 : state.count + 1,
})
);
return (
<fetcher.Form
method="post"
action={`/posts/${postId}/like`}
onSubmit={() => toggleLike(null)}
>
<button type="submit">
{optimisticLike.liked ? "❤️" : "🤍"} {optimisticLike.count}
</button>
</fetcher.Form>
);
}
3.2 Action处理 #
tsx
export async function action({ params, request }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const postId = params.id;
const existing = await getLike(userId, postId);
if (existing) {
await removeLike(userId, postId);
return json({ liked: false });
} else {
await addLike(userId, postId);
return json({ liked: true });
}
}
四、评论系统 #
4.1 乐观评论 #
tsx
import { useOptimistic, Form } from "@remix-run/react";
interface Comment {
id: string;
content: string;
author: { name: string; avatar: string };
createdAt: string;
pending?: boolean;
}
export default function Comments({
comments,
currentUser
}: {
comments: Comment[];
currentUser: { name: string; avatar: string };
}) {
const [optimisticComments, addComment] = useOptimistic(
comments,
(state, newComment: string): Comment[] => [
...state,
{
id: `temp-${Date.now()}`,
content: newComment,
author: currentUser,
createdAt: new Date().toISOString(),
pending: true,
},
]
);
return (
<div>
<ul className="space-y-4">
{optimisticComments.map((comment) => (
<li
key={comment.id}
className={`p-4 bg-white rounded ${comment.pending ? "opacity-50" : ""}`}
>
<div className="flex items-center gap-2 mb-2">
<img
src={comment.author.avatar}
alt=""
className="w-8 h-8 rounded-full"
/>
<span className="font-medium">{comment.author.name}</span>
{comment.pending && (
<span className="text-sm text-gray-500">发送中...</span>
)}
</div>
<p>{comment.content}</p>
</li>
))}
</ul>
<Form
method="post"
onSubmit={(e) => {
const form = e.currentTarget;
const content = new FormData(form).get("content");
if (typeof content === "string" && content.trim()) {
addComment(content.trim());
form.reset();
}
}}
>
<textarea
name="content"
placeholder="写下你的评论..."
required
/>
<button type="submit">发表评论</button>
</Form>
</div>
);
}
五、购物车 #
5.1 乐观购物车更新 #
tsx
import { useOptimistic, useFetcher } from "@remix-run/react";
interface CartItem {
id: string;
productId: string;
name: string;
price: number;
quantity: number;
}
export default function Cart({ items }: { items: CartItem[] }) {
const fetcher = useFetcher();
const [optimisticItems, updateQuantity] = useOptimistic(
items,
(state, { id, quantity }: { id: string; quantity: number }) =>
state.map((item) =>
item.id === id ? { ...item, quantity } : item
)
);
const [itemsWithRemoval, removeItem] = useOptimistic(
optimisticItems,
(state, id: string) => state.filter((item) => item.id !== id)
);
const total = itemsWithRemoval.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
return (
<div>
<ul>
{itemsWithRemoval.map((item) => (
<li key={item.id} className="flex items-center gap-4">
<span>{item.name}</span>
<fetcher.Form method="post">
<input type="hidden" name="intent" value="update" />
<input type="hidden" name="id" value={item.id} />
<button
type="submit"
name="quantity"
value={item.quantity - 1}
onClick={() => updateQuantity({ id: item.id, quantity: item.quantity - 1 })}
>
-
</button>
<span>{item.quantity}</span>
<button
type="submit"
name="quantity"
value={item.quantity + 1}
onClick={() => updateQuantity({ id: item.id, quantity: item.quantity + 1 })}
>
+
</button>
</fetcher.Form>
<button
onClick={() => {
removeItem(item.id);
fetcher.submit(
{ intent: "remove", id: item.id },
{ method: "post" }
);
}}
>
删除
</button>
</li>
))}
</ul>
<div className="font-bold">
总计: ¥{total.toFixed(2)}
</div>
</div>
);
}
六、错误处理 #
6.1 回滚策略 #
tsx
import { useOptimistic, useActionData, useEffect } from "@remix-run/react";
export default function Form() {
const actionData = useActionData<typeof action>();
const [items, addItem] = useOptimistic([], (state, newItem: string) => [
...state,
{ id: "temp", content: newItem, error: false },
]);
// 如果action返回错误,显示错误信息
useEffect(() => {
if (actionData?.error) {
// 错误会自动回滚,因为loader会重新加载真实数据
alert(actionData.error);
}
}, [actionData]);
return (
<Form method="post">
{/* 表单内容 */}
</Form>
);
}
6.2 显示错误状态 #
tsx
export default function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addTodo] = useOptimistic(
todos,
(state, { title, error }: { title: string; error?: boolean }) => [
...state,
{ id: "temp", title, completed: false, error },
]
);
return (
<ul>
{optimisticTodos.map((todo) => (
<li
key={todo.id}
className={`
${todo.pending ? "opacity-50" : ""}
${todo.error ? "border-red-500" : ""}
`}
>
{todo.title}
{todo.error && <span className="text-red-500"> - 添加失败</span>}
</li>
))}
</ul>
);
}
七、最佳实践 #
7.1 使用唯一临时ID #
tsx
const tempId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
7.2 区分乐观状态 #
tsx
interface OptimisticItem extends Item {
pending?: boolean;
error?: boolean;
}
const [items, addItem] = useOptimistic(
realItems,
(state, newItem: Omit<Item, "id">): OptimisticItem[] => [
...state,
{ ...newItem, id: "temp", pending: true },
]
);
7.3 提供视觉反馈 #
tsx
<li className={`
transition-opacity
${item.pending ? "opacity-50 animate-pulse" : ""}
${item.error ? "border-red-500 bg-red-50" : ""}
`}>
{item.content}
</li>
八、总结 #
本章我们学习了:
- useOptimistic:Remix提供的乐观更新Hook
- 点赞功能:乐观更新计数和状态
- 评论系统:乐观添加评论
- 购物车:乐观更新数量和删除
- 错误处理:回滚和错误显示
核心要点:
- 使用 useOptimistic 实现乐观更新
- 为临时项目使用唯一ID
- 提供清晰的视觉反馈
- 处理错误和回滚情况
最后更新:2026-03-28