🐡

useOptimisticが使えなかったパターン

に公開4

useOptimisticが使えなかったパターン

TODOリストに仮タスクIDでタスクを追加する。そのあと、API経由でDBから採番した正式タスクIDで、仮IDを差し替える。

最初にやろうとして失敗した方法

楽観的ToDoリスト(optimisticTodos)にダミーのToDoIDをセットする。そのあと、API経由で取得したDBから採番された本命ToDoIDとダミーToDoIDを差し替えようとした。
結果としてタスク追加中...とは出ますが、最終的にToDoリストには何も登録されません。

import React, { startTransition, useOptimistic, useState } from "react";

interface ITodo {
  id: number;
  content: string;
  isPending: boolean;
}

const Todo = () => {
  const [inputTodo, setInputTodo] = useState("");
  const [todos, setTodos] = useState<ITodo[]>([]);

  const [optimisticTodos, addOptimisticTodo] = useOptimistic<
    ITodo[],
    { dummyId: number; content: string }
  >(todos, (currentState, inputValue) => [
    ...currentState,
    {
      id: inputValue.dummyId,
      content: inputValue.content,
      isPending: true,
    },
  ]);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    const dummyId = Date.now();

    startTransition(async () => {
      addOptimisticTodo({ dummyId, content: inputTodo });

	  // 疑似API(DBから採番されたToDoIDを返却するAPI)
      const realId = await new Promise<number>((resolve) =>
        setTimeout(() => resolve(Math.floor(Math.random() * 10000)), 5000)
      );

      setTodos((prev) =>
        prev.map((x) =>
          x.id === dummyId ? { ...x, id: realId, isPending: false } : x
        )
      );
    });
  };

  return (
    <>
      <div>
        {optimisticTodos.map((todo) => (
          <p key={todo.id}>{todo.isPending ? "タスク追加中..." : todo.content}</p>
        ))}
      </div>
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          value={inputTodo}
          onChange={(e) => setInputTodo(e.currentTarget.value)}
        />
        <button type="submit">タスク追加</button>
      </form>
    </>
  );
};

export default Todo;

失敗内容

addOptimisticTodo({ dummyId, content: inputTodo });をすることで楽観的ToDoリストにはダミーIDのToDoがセットされますが、本命ToDoリスト(Todos)にはセットされません。
そのため、本命ToDoIDとダミーToDoIDを差し替えはできません。

解決策

解決策はシンプルです。useOptimisticを使うのをやめ、最初から本命ToDoリストにダミーIDをセットすることです。

import React, { startTransition, useOptimistic, useState } from "react";

interface ITodo {
  id: number;
  content: string;
  isPending: boolean;
}

const Todo = () => {
  const [inputTodo, setInputTodo] = useState("");
  const [todos, setTodos] = useState<ITodo[]>([]);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    const dummyId = Date.now();

    // 本命ToDoリストにダミーIDをセットする
    setTodos((prev) => [
      ...prev,
      { id: dummyId, content: inputTodo, isPending: true },
    ]);

    startTransition(async () => {
      // 疑似API(DBから採番されたToDoIDを返却するAPI)
      const realId = await new Promise<number>((resolve) =>
        setTimeout(() => resolve(Math.floor(Math.random() * 10000)), 5000)
      );

      setTodos((prev) =>
        prev.map((x) =>
          x.id === dummyId ? { ...x, id: realId, isPending: false } : x
        )
      );
    });
  };

  return (
    <>
      <div>
        {todos.map((todo) => (
          <p key={todo.id}>
            {todo.isPending ? "タスク追加中..." : todo.content}
          </p>
        ))}
      </div>
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          value={inputTodo}
          onChange={(e) => setInputTodo(e.currentTarget.value)}
        />
        <button type="submit">タスク追加</button>
      </form>
    </>
  );
};

楽観的更新を使うことに執着しすぎて、この解決方法に気づくのに時間がかかりました....

Discussion

Honey32Honey32

失礼します。公式ドキュメントにあるコード例を見てみましたが、 addOptimisticTodostartTransition の順番を変えれば解決しそうです。

https://ja.react.dev/reference/react/useOptimistic#optimistically-updating-with-forms

    startTransition(async () => {
      // この、一番外側の startTransition は、onSubmit ではなく action を使えば不要になりそう
      addOptimisticTodo({ dummyId, content: inputTodo });

      startTransition(async () => {
  	    // 疑似API(DBから採番されたToDoIDを返却するAPI)
        const realId = await new Promise<number>((resolve) =>
          setTimeout(() => resolve(Math.floor(Math.random() * 10000)), 5000)
        );

        setTodos((prev) =>
          prev.map((x) =>
            x.id === dummyId ? { ...x, id: realId, isPending: false } : x
          )
        );
    });
noelsunnoelsun

ご助言ありがとうございます!
実は上記のようにstartTransitionの外側にaddOptimisticTodoを記載したのですが、タスク追加できず、かつ、以下エラーメッセージが発生しました。

An optimistic state update occurred outside a transition or action. To fix, move the update to an action, or wrap with startTransition.(遷移またはアクションの外で楽観的な状態更新が発生しました。修正するには、更新をアクションに移動するか、startTransition でラップしてください。)

公式の書き方でエラーになるって...。

import React, { startTransition, useOptimistic, useState } from "react";

interface ITodo {
  id: number;
  content: string;
  isPending: boolean;
}

const Todo = () => {
  const [inputTodo, setInputTodo] = useState("");
  const [todos, setTodos] = useState<ITodo[]>([]);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    const dummyId = Date.now();

    addOptimisticTodo({
      id: dummyId,
      content: inputTodo,
      isPending: true,
    });

    // // 本命ToDoリストにダミーIDをセットする
    // setTodos((prev) => [
    //   ...prev,
    //   { id: dummyId, content: inputTodo, isPending: true },
    // ]);

    startTransition(async () => {
      // 疑似API(DBから採番されたToDoIDを返却するAPI)
      const realId = await new Promise<number>((resolve) =>
        setTimeout(() => resolve(Math.floor(Math.random() * 10000)), 5000)
      );

      setTodos((prev) =>
        prev.map((x) =>
          x.id === dummyId ? { ...x, id: realId, isPending: false } : x
        )
      );
    });
  };

  const [optimisticTodos, addOptimisticTodo] = useOptimistic<ITodo[], ITodo>(
    todos,
    (currentState, payload) => [...currentState, payload]
  );


  return (
    <div className="mt-4">
      <div>
        {optimisticTodos.map((todo) => (
          <p key={todo.id}>
            {todo.isPending ? "タスク追加中..." : todo.content}
          </p>
        ))}
      </div>
      <form onSubmit={handleSubmit}>
        <input
          className="border-2"
          type="text"
          value={inputTodo}
          onChange={(e) => setInputTodo(e.currentTarget.value)}
        />
        <button type="submit" className="bg-green-600 cursor-pointer">
          タスク追加
        </button>
      </form>
    </div>
  );
};

export default Todo;

Honey32Honey32

いえ、公式のコード例のとおりになっていません。公式では form の onSubmit prop ではなく action prop を使っています。

noelsunnoelsun

失礼しました。actionpropを使うように変更したのですが、やはりダミーIDを本命IDに差し替えることはできないようでした。

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

interface ITodo {
  id: number;
  content: string;
  isPending: boolean;
}

const Todo = () => {
  const [todos, setTodos] = useState<ITodo[]>([]);

  const formRef = useRef<HTMLFormElement | null>(null);

  const handleAction = (formData: FormData) => {
    const dummyId = Date.now();

    addOptimisticTodo({
      id: dummyId,
      content: formData.get("task") as string,
      isPending: true,
    });

    formRef.current?.reset();

    startTransition(async () => {
      const realId = await new Promise<number>((resolve) =>
        setTimeout(() => resolve(Math.floor(Math.random() * 10000)), 5000)
      );

      setTodos((prev) =>
        prev.map((x) =>
          x.id === dummyId ? { ...x, id: realId, isPending: false } : x
        )
      );
    });
  };

  const [optimisticTodos, addOptimisticTodo] = useOptimistic<ITodo[], ITodo>(
    todos,
    (currentState, payload) => [payload, ...currentState]
  );

  return (
    <div className="mt-4">
      <div>
        {optimisticTodos.map((todo) => (
          <p key={todo.id}>
            {todo.isPending ? "タスク追加中..." : todo.content}
          </p>
        ))}
      </div>
      <form action={handleAction} ref={formRef}>
        <input name="task" className="border-2" type="text" />
        <button type="submit" className="bg-green-600 cursor-pointer">
          タスク追加
        </button>
      </form>
    </div>
  );
};

export default Todo;