【React 19】useOptimistic で楽観的に UI を更新

# はじめに
この記事では、React 19 で新たに追加された `useOptimistic` という hook による楽観的更新処理について、`useOptimistic` を利用するケース、利用しないケースを比較することで、理解を深めていきます。

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

# 開発環境
開発環境は以下の通りです。

- Windows11
- VSCode
- JavaScript
- React 19.0.0-rc-df5f2736-20240712
- Vite 5.2.0
- Node.js 20.14.0
- npm 10.8.1

# サンプルアプリの概要

- 動作
    - Like ボタンをクリックしたらカウントアップする

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/d116b58d-56d4-3acd-211b-32048720d574.png)

- 内部処理
    - Like ボタンをクリックした3秒後にAPIを呼び出し、クリック数を返す
    - クリック処理実行、実行ステートの管理、実行結果の取得は、`useActionState` を利用する


https://react.dev/reference/react/useActionState


# `useOptimistic` を利用しないケース
クリック数は、`useActionState` の戻り値 `likeCount` で表示します。

```App.jsx
import { useActionState } from "react";
import "./App.css";

function App() {
  const [likeCount, formAction, isPending] = useActionState(async () => {
    await new Promise((res) => setTimeout(res, 3000));

    const response = await fetch("https://example.com/like", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ likeCount }),
    });
    const jsonResponse = await response.json();
    return jsonResponse.likeCount;
  }, 0);
  console.log("🚀 ~ App ~ isPending:", isPending, "\nlikeCount:", likeCount);

  return (
    <form action={formAction}>
      <strong>Like: {likeCount}</strong>
      <hr />
      <button type="submit" disabled={isPending}>
        Like
      </button>
    </form>
  );
}

export default App;
```

クリックしてから3秒後にクリック数が更新されます。

![Vite-React-Google-Chrome-2024-07-13-13-29-38.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/215fbb84-762e-9984-2c70-796f01a7d0f6.gif)


# `useOptimistic` を利用するケース
`useOptimistic` では、楽観的に非同期処理成功後の値を取得できます。楽観的とは、非同期処理完了前に成功後の値を取得できる（成功する前提で処理完了前に値を取得できる）ということです。
そのため、ユーザーは操作した結果をすぐに受け取ることができます。

```
const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);
```

- 引数
  - `state` : `useOptimistic` の管理対象となる値
  - `updateFn(currentState, optimisticValue)` :  戻り値でのひとつある `addOptimistic` の処理が記載された関数（`optimisticState` を返す）
    - `currentState` : 楽観的更新前の `state`
    - `optimisticValue` : `addOptimistic` の引数
- 戻り値
  - `optimisticState` : 楽観的更新を反映した値で、更新処理中以外は `state` と同じ値になる
  - `addOptimistic` : `optimisticState` の更新実行関数

クリック数は、`useOptimistic` の戻り値 `optimisticLikeCount` (`optimisticState`) で表示します。

```App.jsx
import { useOptimistic, useActionState } from "react";
import "./App.css";

function App() {
  const [likeCount, formAction, isPending] = useActionState(async () => {
    addOptimistic(likeCount + 1);

    await new Promise((res) => setTimeout(res, 3000));

    const response = await fetch("https://example.com/like", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ likeCount }),
    });
    const jsonResponse = await response.json();
    return jsonResponse.likeCount;
  }, 0);

  const [optimisticLikeCount, addOptimistic] = useOptimistic(
    likeCount,
    (currentLikeCount, optimisticValue) => {
      return optimisticValue;
    },
  );
  console.log(
    "🚀 ~ App ~ isPending:",
    isPending,
    "\noptimisticLikeCount:",
    optimisticLikeCount,
    "\nlikeCount:",
    likeCount,
  );

  return (
    <form action={formAction} style={{ textAlign: "center" }}>
      <strong>Like: {optimisticLikeCount}</strong>
      <hr />
      <button type="submit" disabled={isPending}>
        Like
      </button>
    </form>
  );
}

export default App;

```

`useOptimistic` を利用しないケースと異なり、クリック直後にクリック数が更新されます。

![Vite-React-Google-Chrome-2024-07-13-13-36-21.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/a4c94353-10eb-9006-9f7f-80102c650af1.gif)

:::note
`useOptimistic` の戻り値 `optimisticLikeCount` (`optimisticState`) は、前述の通り更新処理中以外は `state` と同じ値になります。そのため、楽観的に更新した値が実際に更新した値と異なっていたとしても非同期処理が終われば、実際の値が表示されます。

以下の例では、`optimisticLikeCount` を常に 100 にしています。

```App.jsx
import { useOptimistic, useActionState } from "react";
import "./App.css";

function App() {
  const [likeCount, formAction, isPending] = useActionState(async () => {
    addOptimistic(100); // 誤った値を返す

    await new Promise((res) => setTimeout(res, 3000));

    const response = await fetch("https://example.com/like", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ likeCount }),
    });
    const jsonResponse = await response.json();
    return jsonResponse.likeCount;
  }, 0);

  const [optimisticLikeCount, addOptimistic] = useOptimistic(
    likeCount,
    (currentLikeCount, optimisticValue) => {
      return optimisticValue;
    },
  );
  console.log(
    "🚀 ~ App ~ isPending:",
    isPending,
    "\noptimisticLikeCount:",
    optimisticLikeCount,
    "\nlikeCount:",
    likeCount,
  );

  return (
    <form action={formAction} style={{ textAlign: "center" }}>
      <strong>Like: {optimisticLikeCount}</strong>
      <hr />
      <button type="submit" disabled={isPending}>
        Like
      </button>
    </form>
  );
}

export default App;
```

クリック直後は100になりますが、3秒経過すると（処理が完了すると）、正しいクリック数が表示されます。

![Vite-React-Google-Chrome-2024-07-13-14-11-50.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/0e322c28-a4cf-92ee-6f0e-e5aa6644938f.gif)


:::

# 参考
- [React 19 RC](https://react.dev/blog/2024/04/25/react-19)
- [useActionState](https://react.dev/reference/react/useActionState)
- [useOptimistic](https://react.dev/reference/react/useOptimistic)

# 関連記事

https://qiita.com/Yasushi-Mo/items/027503cbe4b299d99bed

https://qiita.com/Yasushi-Mo/items/184d46d7d877df698c9e

https://qiita.com/Yasushi-Mo/items/3bebc244a97534c96382

https://qiita.com/Yasushi-Mo/items/abaddd44b92ba007e0c7