前端框架

-

React useOptimistic Hook 詳解 - 如何在 React 實作 Optimistic Update?

this.web

React 19 推出了一個新的 Hook,專門用來樂觀更新 - useOptimistic如果你不知道甚麼是樂觀更新,可以參考我寫的這篇文章,裡面有詳細的介紹。

在 React 中如何實現樂觀更新 - useOptimistic

在開始看 useOptimistic 前,我們先將狀態分成兩種。

  1. 真正的狀態:由 useState 返回的 state
  2. 樂觀狀態:預設更新成功時的狀態,由 useOptimistic 返回的 optimisticState (樂觀狀態)。

有了基本觀念之後就可以來看 useOptimistic,他接收兩個參數,也返回兩個值

import { useOptimistic } from 'react';

function App() {
  const [optimisticState, addOptimistic] = useOptimistic(
    state, // 接收的第一個參數:初始 state
    // 接收的第二個參數:處理樂觀更新的函數 updateFn
    (currentState, optimisticValue) => {
      // 回傳生成的樂觀狀態,當非同步處理完成後回傳真正的新值
    }
  );
}

接著讓我們詳細看看 useOptimistic 函數的參數

  • 第一個參數 state: 初始的真正狀態,通常是由 useState 返回的 state。
  • 第二個參數 updateFn(currentState, optimisticValue):是一個函數,它接受當前的真正狀態和傳遞給 addOptimistic 的樂觀值 (optimisticValue),並返回生成的樂觀狀態 (optimisticState)

也就是說這個函數接收兩個參數,真正的狀態樂觀狀態,並返回新的樂觀狀態。

useState 和 useOptimistic 的差別

其實 useOptimistic 和 useState 有點像,一樣回傳兩個值,第一個值是狀態,第二個值是更新狀態的函數。

const initValue = [{todo: '買牛奶'}]

const [state, setState] = useState(initValue);
const [optimisticState, setOptimisticState] = useOptimistic(state,
  (currentState, optimisticValue) => {
    return [
      ...state,
      {todo: optimisticValue}
    ]
  }
)

只不過 useOptimistic 除了接收初始狀態,也接收第二個參數負責生成樂觀狀態。在真正的狀態完成更新前,都會顯示樂觀狀態。

實做 useOptimistic

接著讓我們看看實際應用場景,假設我們有個更新代辦清單的操作,我們希望用戶輸入完新的代辦事項後樂觀更新 UI,而不用等待伺服器回傳成功後才更新 UI。

我們先用 addTodoAction 函數來模擬非同步新增 todo。

// 模擬新增代辦事項的操作
export async function addTodoAction(todo) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (Math.random() > 0.3) {
        resolve(todo);
      } else {
        reject("Failed!");
      }
    }, 1000);
  });
}

接著我們在應用程式主體先寫好基本的 form 樣式,並宣告一個 todos 狀態。

import { useState, useRef } from "react";

// 應用程式主體
export default function App() {
  const formRef = useRef();

  const [todos, setTodos] = useState([{ text: "買牛奶", sending: false }]);

  const formAction = async (formData) => {
     //...
  };

  return (
    <>
      <h1>你的代辦清單</h1>
      <ul>
        {/* 你的代辦清單 */}
      </ul>

      <br />

      <form action={formAction} ref={formRef}>
        <input type="text" name="todo" placeholder="輸入代辦事項" />
        <button type="submit">新增</button>
      </form>
    </>
  );
}

沒有樂觀更新的作法

我們可以直接在 formAction 函數裡面去 request (請求),當 request 成功後再用 setTodos 來更新狀態。

formAction 也是 React 19 更新的特性。可以參考 React 官方的介紹

並在 JSX 裡去 map todos 來渲染所有的 todos。

import { useState, useRef } from "react";

// 應用程式主體
export default function App() {
  const formRef = useRef();

  const [todos, setTodos] = useState([{ text: "買牛奶", sending: false }]);

  // 👇 formAction
  const formAction = async (formData) => {
    const newTodo = formData.get("todo");
    formRef.current.reset();

    try {
      const response = await addTodoAction(newTodo);
      setTodos((prevTodos) => [
        ...prevTodos,
        { text: response, sending: false },
      ]);
    } catch (err) {
      alert("代辦事項添加失敗");
    }
  };

  return (
    <>
      <h1>你的代辦清單</h1>
      <ul>
        {/* 👇 用 map 去渲染所有的 todo */}
        {todos.map((todo, index) => (
          <li key={index}>
            {todo.text}
          </li>
        ))}
      </ul>

      <br />

      <form action={formAction} ref={formRef}>
        <input type="text" name="todo" placeholder="輸入代辦事項" />
        <button type="submit">新增</button>
      </form>
    </>
  );
}

使用樂觀更新的做法

但我們現在用樂觀狀態改善體驗,此時就可以引入 useOptimistic。

我們先宣告 useOptimistic,第一個參數接受由 useState 返回的 todos,第二個參數接收一個生成 optimisticTodos 的函數,也就是新增一個 sending 為 true 的 todo 物件。

import { useOptimistic, useState, useRef } from "react";

// 模擬新增代辦事項的操作
export async function addTodoAction(todo) {
 //...
}

// 應用程式主體
export default function App() {
  // ...
  // 👇 useOptimistic
  const [optimisticTodos, setOptimisticTodos] = useOptimistic(
    todos, // 初始狀態
    // 會先生成樂觀狀態,直到新的 todos state 更新後才會取代樂觀狀態
    (currentTodos, newTodo) => [
      ...currentTodos,
      {
        text: newTodo,
        sending: true,
      },
    ]
  );

  //...

  return (<>{/*...*/}</>)
  
}

接著調整 formAction,在對伺服器請求前,先更新樂觀狀態。並在伺服器回傳成功時更新真正的 todo state,如果失敗,還原樂觀更新的狀態。

這裡用 sending 是為了更清楚看到整個流程,當送出請求的時候,會先使用樂觀狀態 (sending: ture),直到請求回傳成功後,才會更新真正的狀態 (sending: false)

import { useOptimistic, useState, useRef } from "react";

// 模擬新增代辦事項的操作
export async function addTodoAction(todo) {
 //...
}

// 應用程式主體
export default function App() {
  // ...
  const [optimisticTodos, setOptimisticTodos] = useOptimistic(...);

  const formAction = async (formData) => {
    const newTodo = formData.get("todo");

    //👇 樂觀更新 UI
    setOptimisticTodos(newTodo);
    formRef.current.reset();

    // 👇 回傳成功後,用真正的資料更新的 UI
    try {
      const response = await addTodoAction(newTodo);
      setTodos((prevTodos) => [
        ...prevTodos,
        { text: response, sending: false },
      ]);
    } catch (err) {
      // 👇 如果失敗,就將 todo state 還原,用來還原 optimisticTodos
      alert("代辦事項添加失敗");
      setTodos((prevTodos) => [...prevTodos]);
    }
  };
  
  //...

  return (<>{/*...*/}</>)
}

最後,在 JSX 使用樂觀狀態的值去 map。

import { useOptimistic, useState, useRef } from "react";

//...

  return (
    <>
      <h1>你的代辦清單</h1>
      <ul>
        {/* 👇 map 樂觀狀態 */}
        {optimisticTodos.map((todo, index) => (
          <li key={index}>
            {todo.text}
            {/* 👇 當真正狀態更新前,會顯示由 useOptimistic 生成的樂觀狀態*/}
            {/* 也就是 todo.sending 會是 true*/}
            {!!todo.sending && <span>(Sending...)</span>}
          </li>
        ))}
      </ul>

      <br />

      <form action={formAction} ref={formRef}>
        <input type="text" name="todo" placeholder="輸入代辦事項" />
        <button type="submit">新增</button>
      </form>
    </>
  );
}

此時就可以體驗樂觀更新的 UI了!

如果伺服器回傳成功,就會直接用新的 state 覆蓋樂觀 state 👇

如果失敗,也會直接取消 UI 的更新。

下面附上完整的程式碼,也可以參考完整程式碼示範

import { useOptimistic, useState, useRef } from "react";

// 模擬新增代辦事項的操作
export async function addTodoAction(todo) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (Math.random() > 0.3) {
        resolve(todo);
      } else {
        reject("Failed!");
      }
    }, 1000);
  });
}

// 應用程式主體
export default function App() {
  const formRef = useRef();

  const [todos, setTodos] = useState([{ text: "買牛奶", sending: false }]);
  
  // useOptimistic
  const [optimisticTodos, setOptimisticTodos] = useOptimistic(
    todos, // 初始狀態
    // 會先生成樂觀狀態,直到新的 todos state 更新後才會取代樂觀狀態
    (currentTodos, newTodo) => [
      ...currentTodos,
      {
        text: newTodo,
        sending: true,
      },
    ]
  );

  const formAction = async (formData) => {
    const newTodo = formData.get("todo");

    // 樂觀更新 UI
    setOptimisticTodos(newTodo);
    formRef.current.reset();

    // 回傳成功後,用真正的資料更新的 UI
    try {
      const response = await addTodoAction(newTodo);
      setTodos((prevTodos) => [
        ...prevTodos,
        { text: response, sending: false },
      ]);
    } catch (err) {
      // 如果失敗,就將 todo state 還原,用來還原 optimisticTodos
      alert("代辦事項添加失敗");
      setTodos((prevTodos) => [...prevTodos]);
    }
  };

  return (
    <>
      <h1>你的代辦清單</h1>
      <ul>
        {/* map 樂觀狀態 */}
        {optimisticTodos.map((todo, index) => (
          <li key={index}>
            {todo.text}
            {/* 當真正狀態更新前,會顯示由 useOptimistic 生成的樂觀狀態*/}
            {/* 也就是 todo.sending 會是 true*/}
            {!!todo.sending && <span>(Sending...)</span>}
          </li>
        ))}
      </ul>

      <br />

      <form action={formAction} ref={formRef}>
        <input type="text" name="todo" placeholder="輸入代辦事項" />
        <button type="submit">新增</button>
      </form>
    </>
  );
}

小結

今天使用了 React 19 新的 Hook useOptimisticReact 19 更新了許多異步操作的 Hook,很推薦去看看,這些 Hook 都可以幫助我們打造體驗更好的應用程式。

相關系列文章