🤔

【React】useOptimisticってどうなの?調べてみました!

2024/07/16に公開

React 19系から追加されたuseOptimisticというAPIを弄った所感を述べていきます。

楽観的更新とは

楽観的更新とは、フロントエンドの操作を通してサーバーへの反映を行うとき、そのレスポンスを待たずにUIを更新する手法です。

  • 通常の更新: UIを操作 → サーバーにリクエスト → レスポンスが返る → UIに反映
  • 楽観的更新: UIを操作 → サーバーにリクエスト → 想定したレスポンスが返った場合のUIを反映 → レスポンスが返り、その結果に応じて後続処理を行う。(例: エラーの場合はもとに戻すなど)

身近な場合としては、Twitterでいいねボタンやフォローボタンをクリックしたときの挙動を思い浮かべてみてください。明らかにデータベースの中の状態が更新される前にUIが先に切り替わります。

楽観的更新のユースケース

楽観的更新は操作の軽快さを代償に厳密性・安定性・シンプルさを欠き、実装で考慮することも飛躍的に増えます。例えばエラーの場合はロールバック・リトライさせる等の検討が必要です。そのため使い所が肝心です。

決して銀の弾丸ではなく、プロジェクトによっては一度も必要な場面は出ないでしょう。

useOptimisticとは何なのか、それを使った方がいいのか

useOptimisticはReact 19で新登場した、楽観的更新の実装に使うAPIです。これで状態管理がいい感じにできるらしいです。

特筆すべき点ではTransitionやForm Actionとかいう他の新機能も関わっている...ということですが、そもそもそれらの新しい分野を学ぶことも含めてメリットがあるのか? 引っくるめて、useOptimisticはこれから信頼してもよいのか? という点を心配に思う方もいらっしゃると思います。

ということでちょっと実験してみました。

  • Next.js 14系で確認しています。
  • 筆者はSWRやTanstack Queryによる楽観的更新の機能を使ったことがなく、本記事でもその点は視野に入っていないことをご了承ください。

題材

コメントがズラッと並ぶ投稿画面を想定します。

なお、バックエンドと通信するAPIは次のように呼び出すこととします。

コメント一覧の取得

await commentApi.getComments()
// コメント一覧が返る
[
  {
    "id": "1",
    "createdAt": "2024-06-07T14:55:18.251Z",
    "comment": "はじめまして。"
  }
]

コメントの作成

// 注: この処理は一定確率でエラーになります。
await commentApi.addComment("新しいコメント")

(a) 素の実装

useOptimistic周辺の知識を使わず、シンプルに実装します。

データフェッチの実装

Server Stateの実装は、近年ではSWRやTanstack Queryを使うことが多いでしょう。しかし単純なもので済むので、素で書いていきます。

export interface CommentResponse {
  id: string;
  comment: string;
  createdAt: string;
}
// useCommentsData.ts
export const useCommentsData = () => {
  const [comments, setComments] = useState<CommentResponse[]>();

  const refresh = async () => {
    const result = await commentApi.getComments();
    setComments(result);
  };

  useEffect(() => {
    let ignore = false;

    const getData = async () => {
      const result = await commentApi.getComments();
      if (!ignore) {
        setComments(result);
      }
    };

    getData();

    return () => {
      ignore = true;
    };
  }, []);

  return {
    comments,
    setComments,
    refresh,
  };
};

画面の仕様

まずこれを定義します。
極力シンプルな仕様で検証します。(エラーハンドリングも雑)

<送信中のとき>
「作成中」メッセージを横に出す。

<送信完了したとき>
一覧をリロードし、完了したら送信日時を横に出す。

<送信失敗したとき>
リロードを促すアラートを表示する。

画面側データロジックの実装

楽観的更新により作成されたレコードは、それが「仮」であると判別できる必要があります。
今回はIDを頼りにしましょう。

import { uuid } from "uuidv4";

/**
 * 楽観的UIのコメントを作成する
 */
export const createOptimisticComment = (
  newComment: string,
): CommentResponse => {
  return {
    id: `mock-id-${uuid()}`,
    comment: newComment,
    createdAt: "",
  };
};

/**
 * 楽観的UIのコメントかどうか判定する
 */
export const isOptimisticComment = (
  comment: CommentResponse | undefined,
): boolean => {
  return comment?.id.startsWith("mock-id-") || false;
};

メインコンポーネント

今まで用意したモジュールを総動員して本画面を実装していきます。
最も重要な処理はonSubmitハンドラに含まれています。

export const CommentListStable = () => {
  const { comments, setComments, refresh } = useCommentsData();

  const [inputText, setInputText] = useState("");

  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    // 楽観的データを追加する
    setComments((state) => [
      ...(state || []),
      createOptimisticComment(inputText),
    ]);

    try {
      // コメント追加APIを叩く
      await commentApi.addComment(inputText);
      // コメント一覧APIを叩く
      await refresh();
    } catch (error) {
      // リロードを促す
      alert("エラーが発生しました。リロードしてください。");
      window.location.reload();
    }

    setInputText("");
  };

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          value={inputText}
          onChange={(e) => setInputText(e.target.value)}
        />
        <button disabled={inputText.trim().length === 0}>投稿</button>
      </form>

      <ul>
        {comments?.toReversed().map((comment) => (
          <li key={comment.id}>
            {comment.comment}{" "}
            {isOptimisticComment(comment) ? "作成中" : comment.createdAt}
          </li>
        ))}
      </ul>
    </div>
  );
};

ここで最も本質的なポイントは、APIのレスポンスを格納するstateについて 「取得成功 → 楽観的の更新成功 → 実際の更新成功」と分け、そこを情報源とする ことです。

この例は実用上は色々と穴がありますが、手順は複雑ではないので容易く分かるかと思います。
続いて、これがuseOptimisticでどのように変わるかを見ていきます。

(b) useOptimisticで実装

https://ja.react.dev/reference/react/useOptimistic

詳細は上記の公式ドキュメントに頼るとして、前回から次の箇所が変わったことを確認しましょう。

  • 楽観的データの追加は、実装の詳細がuseOptimisticの中に吸収されました。
    • stateとactionを受け取ってstateを返す関数により更新を定義し、actionに対応するdispatch操作を行うことで更新を実行できます。
  • 一連のプロセスがstartTransitionの中に移動しました。
    • useOptimisticを利用した更新処理はstartTransitionまたはForm Actionsの中で書く必要があります。Reactチームの将来的な意図は後者だろうと思いますが、現状で通用する最も基礎的な例を示したかったため前者に揃えました。
  • レスポンスを反映するstateが、APIを正常に反映した本流楽観的更新にも追従した亜流の2つに分けられました。そして後者がUIを描画する情報源へと変わりました。
+ const [optimisticComments, addOptimisticComment] = useOptimistic(
+   comments,
+   (state, newComment: string) => [
+     ...(state || []),
+     createOptimisticComment(newComment),
+   ],
+ );

  // 略...

  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();

-   setComments((state) => [
-     ...(state || []),
-     createOptimisticComment(inputText),
-   ]);

+   startTransition(async () => {
      // 楽観的データを追加する
+     addOptimisticComment(inputText);

      try {
        // コメント追加APIを叩く
        await commentApi.addComment(inputText);
        // コメント一覧APIを叩く
        await refresh();
      } catch (error) {
        // リロードを促す
        alert("エラーが発生しました。リロードしてください。");
        window.location.reload();
      }

      setInputText("");
+   });
  };

  // 略...

      <ul>
-       {comments?.toReversed().map((comment) => (
+       {optimisticComments?.toReversed().map((comment) => (
          <li key={comment.id}>

ここまでのまとめ

改めて整理すると、useOptimisticの最大の特徴は次の2点といえます。

Reducerライクな更新

お気づきかもしれませんが、Stateの更新はActionをDispatchするというuseReducerにそっくりな操作になっています。公式Docsからは若干わかりづらいですが、実はTypeScriptの型定義もそれを意識して書かれていることが読み取れます。

node_modules/@types/react/canary.d.ts
    export function useOptimistic<State>(
        passthrough: State,
    ): [State, (action: State | ((pendingState: State) => State)) => void];
    export function useOptimistic<State, Action>(
        passthrough: State,
        reducer: (state: State, action: Action) => State,
    ): [State, (action: Action) => void];

ここはuseState的な手法でも良かったかなと個人的に思いますが、恐らく公式の意向でしょう。

Stateが「本流」と「亜流」の2種類になる

大抵はレスポンスを受け取ったStateを1つのままで管理するかと思います。それは無難な発想ですが、次第に処理が膨れ上がって実装方針の統一がままならなかったり状態遷移が変になって手がつけられなくなる危険性も潜んでいます。(そのような光景も度々目にしました)

ここでuseOptimisticを使うと、レスポンスを受け取ったStateがあり、そこから楽観的更新も取り入れては商した新しいStateの2つが作られるという段階により、責務が明確になります。このアプローチにより堅牢さやデバッグの容易さが予想外に上がりそうです。

(c) 素の実装 (強化版)

(a)(b)はすごく簡単なサンプルでしたが、もうちょっと仕様を実用的にしてみます。

画面の仕様

<初期表示>

<送信中のとき>
「投稿」ボタンを非活性にする。
「投稿中」メッセージを横に出す。

<送信完了したとき>
「投稿」ボタンを活性にする。
一覧をリロードし、<一覧リロード中のとき>に移る。

<一覧リロード中のとき>
上部メッセージを「一覧を読み込み中...」にする。
完了したら、<一覧リロード完了したとき>に移る。

<一覧リロード完了したとき>
上部メッセージを「一覧を読み込みました。 (N件)」に戻す。
コメントの横に送信日時を出す。

<送信失敗したとき>
トーストを表示する。
送信失敗したコメントには「再投稿」ボタンを横に出す。
「再投稿」ボタンがクリックされたら、<送信中のとき>に移る。

データフェッチの実装

  • <送信失敗したとき>の要件により、エラー時を判別できるクライアント側の情報が必要になります。
  • Loading Stateも追加します。
useCommentsData.ts
// useCommentsData.ts
+export type CommentState = CommentResponse & {
+  isError?: boolean;
+};

export const useCommentsData = () => {
- const [comments, setComments] = useState<CommentResponse[]>();
+ const [comments, setComments] = useState<CommentState[]>();
+ const [loading, setLoading] = useState(false);

  const refresh = async () => {
+   setLoading(true);

+   try {
      const result = await commentApi.getComments();
      setComments(result);
+   } finally {
+     setLoading(false);
+   }
  };

  useEffect(() => {
    let ignore = false;

+   setLoading(true);

    const getData = async () => {
+     try {
        const result = await commentApi.getComments();
        if (!ignore) {
          setComments(result);
+       }
+     } finally {
+       if (!ignore) {
+         setLoading(false);
+       }
      }
    };

    getData();

    return () => {
      ignore = true;
    };
  }, []);

  return {
    comments,
    setComments,
+   loading,
    refresh,
  };
};

画面側データロジックの実装

コメントを送信するとき、対象が「新規作成」なのか「投稿しようとしたが失敗したエラーデータ」なのかで二手に分かれます。このとき一覧に楽観的データが存在するかどうかを判定して分岐します。なので判別用のヘルパーを追加します。

import { uuid } from "uuidv4";
+import type { CommentResponse } from "@/backend/api-client";

// 略...

+/**
+ * 楽観的UIのコメントを検索する
+ */
+export const findOptimisticComment = (
+  comments: CommentResponse[] | undefined,
+): CommentResponse | undefined => {
+  return comments?.find(isOptimisticComment);
+};

Presentationalコンポーネントの実装

ゴチャゴチャしてきたのでコメント一覧のViewだけを切り出します。

CommentListView.tsx
export type CommentListViewProps = Partial<
  ReturnType<typeof useCommentsData>
> & {
  onRetry?: (selectedComment: string) => void;
};

export const CommentListView = ({
  loading,
  comments,
  onRetry,
}: CommentListViewProps) => {
  if (!comments) {
    return null;
  }

  const loadedComments = comments.filter(
    (comment) => !isOptimisticComment(comment),
  );

  return (
    <div>
      <p>
        {loading
          ? "一覧を読み込み中..."
          : `一覧を読み込みました。 (${loadedComments?.length}件)`}
      </p>

      <hr />

      <ul>
        {[...comments].reverse()?.map((comment) => {
          if (comment.isError) {
            return (
              <li key={comment.id} style={{ display: "flex" }}>
                <div>{comment.comment}</div>

                {comment.isError && (
                  <button
                    type="button"
                    onClick={() => onRetry?.(comment.comment)}
                    style={{ marginLeft: 8 }}
                  >
                    再投稿
                  </button>
                )}
              </li>
            );
          }

          return (
            <li key={comment.id} style={{ display: "flex" }}>
              <div>
                {comment.comment}{" "}
                <span style={{ color: "rgb(120,120,120)", fontSize: 14 }}>
                  {isOptimisticComment(comment)
                    ? "投稿中..."
                    : `(${format(
                        comment.createdAt,
                        "yyyy年MM月dd日 hh:mm:ss",
                      )} に投稿)`}
                </span>
              </div>
            </li>
          );
        })}
      </ul>
    </div>
  );
};

メインコンポーネント

差分表示だと却って複雑になるため、全量を載せます。
展開して貰えると確認できますが、イベントハンドラの中身が多少膨れ上がりました。

CommentListStable.tsx
export const CommentListStable = () => {
  const { comments, setComments, loading, refresh } = useCommentsData();

  const [inputText, setInputText] = useState("");

  const [creating, setCreating] = useState(false);

  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    postComment(inputText);
  };

  const handleRetry = (selectedComment: string) => {
    postComment(selectedComment);
  };

  const postComment = async (newComment: string) => {
    setCreating(true);
    try {
      // 楽観的データが既に作成済か調べる
      const optimisticComment = findOptimisticComment(comments);
      // 楽観的データがない場合
      if (!optimisticComment) {
        // 楽観的データを追加する
        setComments((state) => [
          ...(state || []),
          createOptimisticComment(inputText),
        ]);
      } else {
        // 楽観的データがあれば、それはエラーコメントと判定されている。
        // 楽観的データをエラー判定から解除する
        setComments((state) =>
          state?.map((comment) => {
            if (isOptimisticComment(comment)) {
              return { ...comment, isError: false };
            } else {
              return comment;
            }
          }),
        );
      }

      await commentApi.addComment(newComment);
      await refresh();

      setInputText("");
    } catch (error) {
      // 楽観的データをエラー判定にする
      setComments((state) =>
        state?.map((comment) => {
          if (isOptimisticComment(comment)) {
            return { ...comment, isError: true };
          } else {
            return comment;
          }
        }),
      );

      toast.error("投稿に失敗しました。");
    } finally {
      setCreating(false);
    }
  };

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          value={inputText}
          onChange={(e) => setInputText(e.target.value)}
        />
        <button disabled={inputText.trim().length === 0 || creating}>
          投稿
        </button>
      </form>

      <CommentListView
        loading={loading}
        comments={comments}
        onRetry={handleRetry}
      />
    </div>
  );
};

問題は、useOptimisticでも同様にスケールするか?というところです。

(d) useOptimisticで実装 (強化版)

理論上は次のように変更すれば良いはずですが、上手く行きませんでした。

CommentListUseOptimistic.tsx
// 状態が増えたので、Reducerをしっかり書きます

+type OptimisticCommentsAction =
+  | { type: "add"; newComment: string }
+  | { type: "error" }
+  | { type: "revert-error" };
+
+const optimisticCommentsReducer = (
+  state: CommentResponse[] | undefined,
+  action: OptimisticCommentsAction,
+) => {
+  switch (action.type) {
+    case "add": {
+      // 偽のデータを作成する
+      return [...(state || []), createOptimisticComment(action.newComment)];
+    }
+    case "error": {
+      // 偽のデータをエラー判定にする
+      return state?.map((comment) => {
+        if (isOptimisticComment(comment)) {
+          return { ...comment, isError: true };
+        } else {
+          return comment;
+        }
+      });
+    }
+    case "revert-error": {
+      // 偽のデータをエラー判定から解除する
+      return state?.map((comment) => {
+        if (isOptimisticComment(comment)) {
+          return { ...comment, isError: false };
+        } else {
+          return comment;
+        }
+      });
+    }
+    default:
+      return state;
+  }
+};

export const CommentListUseOptimistic = () => {
 // ...
+ const [optimisticComments, dispatchOptimisticComments] = useOptimistic(
+   comments,
+   optimisticCommentsReducer,
+ );

 // ...

  const postComment = async (newComment: string) => {
    setCreating(true);
+   startTransition(async () => {
      try {
        // 楽観的データが既に作成済か調べる
        const optimisticComment = findOptimisticComment(comments);
        // 楽観的データがない場合
        if (!optimisticComment) {
          // 楽観的データを追加する
+         dispatchOptimisticComments({ type: "add", newComment });
        } else {
          // 楽観的データがあれば、それはエラーコメントと判定されている。
          // 楽観的データをエラー判定から解除する
+         dispatchOptimisticComments({ type: "revert-error" });
        }

        await commentApi.addComment(newComment);
        await refresh();

        setInputText("");
      } catch (error) {
        // 楽観的データをエラー判定にする
+       dispatchOptimisticComments({ type: "error" });
        toast.error("投稿に失敗しました。");
      } finally {
        setCreating(false);
      }
+   });
  };

 // ...

      <CommentListView
        loading={loading}
-       comments={comments}
+       comments={optimisticComments}
        onRetry={handleRetry}
      />

// ...

}

上手くいかない箇所はどこかというと、送信エラーのときにコメント一覧が楽観的データ専用のUIにならずロールバックされてしまっています。

これはcatch節に処理が入ったらロールバックされる気がしますが、ちょっと調査できていません。もしそれが原因なら、APIクライアントはエラーをthrowしないよう修正すれば済むでしょう。

結論

useOptimisticは楽観的更新用データの管理を行ってくれる一風変わったuseReducer
まだまだ様子見だけど今後に期待しましょう!

リポジトリ: https://github.com/yha-1228/optimistic-example/tree/main

Discussion