【翻译】`useOptimistic`无法拯救你

4 阅读7分钟

原文链接: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. 纯粹炫技

最后这个示例结合使用了useActionStateuseOptimistic。我原本写了些重复代码,但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的实现,也无法独立解决竞争条件问题。

要正确使用它,你必须真正理解过渡、动作以及 useTransitionuseActionsState 这样的钩子。

之所以想写这篇文章,是因为我自己也花了大量时间才弄明白这些概念。你绝对想不到 Medium 上有多少关于 useOptimistic 的文章,竟然连过渡机制都不提。

坦白说,这些API应该交给库和框架作者来处理。我建议你看看Ricky Hanlon在React大会上的演讲异步React。在一段充满失误的演示后,Ricky坦言:

"所以关键点在于——你们也看到我挣扎了——老实说,编写这些独立功能实在令人头疼。"

问题正是如此。React团队曾因建议搭配框架使用引发争议,但原因就在于此。这些新API不仅令人头疼,既未能减少冗余代码量,也无法降低引入错误的概率。

其初衷是让框架作者利用这些API构建路由器和数据层,使我们能享受其优势,而无需亲自应对复杂性。