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>

八、总结 #

本章我们学习了:

  1. useOptimistic:Remix提供的乐观更新Hook
  2. 点赞功能:乐观更新计数和状态
  3. 评论系统:乐观添加评论
  4. 购物车:乐观更新数量和删除
  5. 错误处理:回滚和错误显示

核心要点:

  • 使用 useOptimistic 实现乐观更新
  • 为临时项目使用唯一ID
  • 提供清晰的视觉反馈
  • 处理错误和回滚情况
最后更新:2026-03-28