【図解解説】React19の新機能を使って書籍管理アプリを開発するチュートリアル【Hono/TypeScript/TailwindCSS】


![React19 (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/4ee54b86-dd7c-d0d8-c91b-7f0097a7eddc.png)


# はじめに

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/5f344861-4fe2-e383-9988-e5d799a00165.png)

#### ついにReact19が安定版になりました！！！！

React19になったことで「サーバーコンポーネントの正式対応」や「アクションの追加」など大きな変更が入りました。

この変更によってShadcnなど多くのライブラリが対応を頑張っている状況です(おそらく裏では...)

今回はそんなReact19の中でも特に**知っておきたい機能**を中心に紹介していきます。
ちなみにReact19の機能は実験的に少し前から公開されており、世の中にはすでに多くの記事やYoutube動画があります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/4a4e9e1e-1b2f-1c84-30d8-2d19b0f1e4d0.png)


しかしそれらの記事や動画を見て思いました…

#### <font color="red">**自分の能力が低いせいか理解はできるけど、実際に開発の中で使えるイメージがわかない！！**</font>
(つまり理解した気になっているだけ)

いままでにもReactを追ってはいますが、やはり過去を振り返ってもその場で理解はしているけど使えている実感はありません。
　
　
　
なぜその場では理解できているけど、実際に使うまで身につかないのか…
　
　
　
**理由は実際に開発の中で使ったことがないから。**
ということで今回のチュートリアルは以下の構成で行っています。

- React19の新機能をざっくり知る
- 具体的な例でそれぞれの機能を掘り下げる
- 書籍管理アプリを通して実際に利用して身につける

　
ハンズオンを通して実際に利用することで深く理解して使いこなせるようになります。
このようなハンズオンが調べた限りは全然ありませんでしたので、ぜひ活用して最新のReactを一気にキャッチアップしていただけると嬉しいです。

今回はこのようなアプリを作ります👇

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/0e935ea8-9f05-57ef-524e-d2ca6f04d537.png)


# 動画教材も用意しています

こちらの教材にはより詳しく解説した動画もセットでご用意しています。
テキスト教材ではわからない細かい箇所があれば動画も活用ください。

<iframe width="560" height="315" src="https://www.youtube.com/embed/_TUgbgIirus?si=GBmYytakvQ22uGEC" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

https://github.com/jinwatanabe/react19-book-manager

# 対象者

- 最新のReactをキャッチアップしたい
- Reactをやってみたい初心者
- HonoでAPIを作成したい
- 更新された機能を使いこなせるようになりたい
- JavaScriptを少し理解している
- アプリを作って体系的に学びたい

# React19の機能を知る

まず初めに今回React19で追加された<font color="red">**アクション**</font>を中心に特に使えるようになっておきたい機能について解説します。

ここではあくまで機能を理解するだけになります。
実際に新機能を使いこなせるようにするためにハンズオンも行うようにして下さい。

React19に関しては[こちら](https://ja.react.dev/blog/2024/12/05/react-19)でも理解できますが、より初心者向けに噛み砕いて解説をしていきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/2de9bf2d-38b4-2551-f05c-2dec340053ad.png)

詳しく解説するのは青くなっている機能です。
今回の変更で**アクション**が多く追加されました。

アクションとは非同期処理(API叩くなど)をするような機能だと思ってください
このアクションを理解する上で**Suspense**も外せないので先に説明します。
　

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/a123c443-aa2e-3bf5-5736-f49ab692d809.png)

まずはこれがSuspenseがないコードです。
`useEffect`(画面が表示される前に実行される処理)の中で`fetchUserData`(ユーザーに関する情報を取得する処理)があります。

この処理が実行されている間は画面が表示されなくなってしまうので、`isLoading`というステートをtrueにしてローディング画面をデータ取得中に表示するようにしています。

```tsx
if (isLoading) return <LoadingSpinner />;
```

このように取得中をステート管理したり、ローディング画面かを条件分岐したりと大変です。
　

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/6a2f102c-d975-9126-9226-f1e805b3fa10.png)

ここで活躍するのが<font color="red">**Suspense**</font>という機能です。
Suspenseタグで囲んだ中で非同期処理(Promise)を実行するとPromiseが解決するまでサスペンド状態となります。

そのサスペンドを<Suspense>が検知してローディング画面を表示してくれます。
非同期処理が終わるとサスペンドは解除されてユーザー詳細画面が表示されます。

**これまでのReactは<Suspense>で取得系(GET)などの非同期処理はうまく扱えたのですが、更新系(PUTやPATCH)などはうまく取り扱えませんでした。**


今回追加されたアクションで更新系の非同期処理もうまく扱えるようになりました。

## 1. use

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/0a895e75-0197-f956-2dab-a7a6c19c240a.png)

useの引数にPromiseを渡すことで解決してくれるようになりました
これまでuseEffectで非同期処理を解決してところを`use`のみで解決できます。

## 2. useTransition

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/ddf6a4a9-4f35-de16-b7dc-621101aaaebe.png)

状態更新の優先度を下げてくれるフックです。
こうすることで状態更新の前に重い処理がある場合にユーザー操作がブロックされてしまうのを防ぐことができます。

useTransitionだけで使うことはあまりなく、この後紹介するアクションの土台となっている機能です。

## 3. useActionState

![React19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/c5246936-dec1-d25b-9d99-85bd8cd28e7f.png)


非同期処理をしたあとの結果でステート更新ができる機能です。
useTransition + useReducerのような機能で、非同期処理を待っている間は`isPending`を利用してローディング画面などをだすことも可能です。

そのあと処理が終わったらステートが更新されるので、画面の更新(再レンダリング)が行われます。

## 4. formがアクションに対応

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/39747cea-437c-6b7e-ccec-eecd522adc5b.png)

formタグがアクションに対応しました。
`action`属性にアクションを指定することができるようになり、Submitイベントが発火すると実行されるようになります。

またアクションで実行されたときには`formData.get`を利用することでフォームの中身をとりだすこともできます。これによって**react-hook-form**などを利用しなくても簡単に扱えます。

## 5. useOptimistic

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/142db252-8f66-69aa-c028-54b81b67b561.png)

**楽観的更新**を実装するためのフックです。
楽観的更新とは、ユーザーのアクション結果を即座に画面に反映して、実際の処理(この例ではいいねをつける処理)は裏側で行う更新方法です。

楽観的更新をすることによって、ユーザーにはすぐに操作が反映されたように見えるのでユーザビリティが大きく向上します。
　

ここまでで書籍管理アプリに利用する新機能をざっくり解説したので、実際に使ってより深く理解していきましょう！

# 1. React19の環境構築

今回はランタイムにNode.jsを利用するので環境がない方はご自身のOSに合わせた方法で以下のサイトからインストールをお願いします！

https://nodejs.org/en

今回はViteを使ってReact環境を用意します。
ただし普通にViteだけで環境構築するとReact18になるので少し工夫をします。

```
$ mkdir boook-manager
$ cd book-manager
$ npm create vite@latest
✔ Project name: … frontend
✔ Select a framework: › React
✔ Select a variant: › TypeScript

$ cd frontend
$ npm i
$ npm run dev
```

[http://localhost:5173](http://localhost:5173)を開いてサーバーが起動できたことを確認します。
　

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/22478fa3-36cd-2523-4625-303d9c8bbc81.png)

続いてReactのバージョンを確認します。VSCodeでディレクトリを開いて`package.json`をみます
　
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/5d43fbda-8624-15b0-e5da-32bf86362e3d.png)

Reactのバージョンは18系となっています。
今回の新機能は19になって安定版となったためバージョンを19に変えていきます。

```
$ npm i react@19 react-dom@19 @types/react@latest @types/react-dom@latest
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/09271e42-0f75-1d19-5533-7870a9aaecd8.png)

これでReact19で開発する環境が整いました。

次にデザインのためにTailwindCSSをインストールしておきます。

```
$ npm install -D tailwindcss@3.4.13 postcss autoprefixer
$ npx tailwindcss init -p
```

プロジェクトをVSCodeで開いて`tailwind.config.js`を以下に変えます

```js:tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./src/**/*.{js,jsx,ts,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}
```

`src/index.css`を変更します

```css:src/index.css
@tailwind base;
@tailwind components;
@tailwind utilities;
```

`frontend/src/App.tsx`を変更してスタイルがあたるかをチェックします

```tsx:frontend/src/App.tsx
function App() {
  return (
    <>
      <div>
        <button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
          Button
        </button>
      </div>
    </>
  );
}

export default App;
```

一度サーバーを落として`npm run dev`で起動したらボタンが表示されました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/a844a950-e881-3dac-16cb-792dd2c4f430.png)

それではここからReact19の機能を活用して書籍管理アプリを開発していきます。

# 2. HonoでAPIを開発する

まずは今回書籍のデータを保存していくためのAPIをHonoを使って作成します。
データベースなどを用意すればよいのですが、このハンズオンの本質ではないためメモリを使ってデータ管理をしていく構成にします。(サーバーを再起動すると初期値に戻る)

まずはバックエンドプロジェクトを作成しましょう

```
$ cd ..   # book-managerに移動
$ npm create hono@latest

? Target directory backend
? Which template do you want to use? nodejs
? Do you want to install project dependencies? yes
? Which package manager do you want to use? npm
$ cd backend
```

VSCodeでバックエンドのプロジェクトを開きます。
`src/index.ts`を開くと雛形がすでに用意されているのでここにAPIを追加していきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/b878e5a4-4b6b-049e-3614-5b1ae8f24446.png)

　
今回作成するAPIエンドポイントは以下の3つです。

- すべての書籍を取得する (GET /books)
- キーワード検索してヒットした書籍を取得する (GET /books)
- 書籍を新規追加する (POST /books)
- 書籍のステータス(在庫あり・貸出中・返却済)を更新する (PUT /books/:id)

すべての初期取得とキーワード検索は同じエンドポイントの中で行うことにします。

## 2-1. 書籍の取得

まずは書籍を取得するAPIから作成します。

```ts: backend/src/index.ts
import { serve } from "@hono/node-server";
import { Hono } from "hono";

const app = new Hono();

type BookManger = {
  id: number;
  name: string;
  status: string;
}; // 追加

const bookManager: BookMangger[] = [
  { id: 1, name: "React入門", status: "在庫あり" },
  { id: 2, name: "TypeScript入門", status: "貸出中" },
  { id: 3, name: "Next.js入門", status: "返却済" },
]; // 追加

app.get("/books", async (c) => {
  return c.json(bookManager);
}); // 追加

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

const port = 8080; // 修正
console.log(`Server is running on http://localhost:${port}`);

serve({
  fetch: app.fetch,
  port,
});
```

まずはテストデータと今回扱う書籍管理データの型を用意します。

```ts
type BookMangger = {
  id: number;
  name: string;
  status: string;
};

const bookManager: BookMangger[] = [
  { id: 1, name: "React入門", status: "在庫あり" },
  { id: 2, name: "TypeScript入門", status: "貸出中" },
  { id: 3, name: "Next.js入門", status: "返却済" },
];
```

本来であればテーブル設計と考えると微妙ではありますが、チュートリアルの本質でないためこのようなドメインにしました。

すべての書籍を返すエンドポイント(GET /books)はこのように作りました。
現在はテストデータをすべて返すようになっています。

```ts
app.get("/books", async (c) => {
  return c.json(bookManager);
});
```

APIのポートはフロントエンドでよく使う3000をさけたかった8080に変更しました。

```ts
const port = 8080;
```

それでは実際にAPIを叩いて確認していきます。

```
$ npm run dev // backendディレクトリで
$ curl localhost:8080/books

[{"id":1,"name":"React入門","status":"在庫あり"},{"id":2,"name":"TypeScript入門","status":"貸出中"},{"id":3,"name":"Next.js入門","status":"返却済"}]
```

いい感じに返ってきました。では次にキーワード検索にも対応します。
キーワードはクエリで受け取るようにします。`localhost:8080/books?keyword=React`が叩かれたら`React`をタイトルに含む書籍を返すようにします。

```ts:backend/src/index.ts
import { serve } from "@hono/node-server";
import { Hono } from "hono";

const app = new Hono();

type BookMangger = {
  id: number;
  name: string;
  status: string;
};

const bookManager: BookMangger[] = [
  { id: 1, name: "React入門", status: "在庫あり" },
  { id: 2, name: "TypeScript入門", status: "貸出中" },
  { id: 3, name: "Next.js入門", status: "返却済" },
];

// 修正
app.get("/books", async (c) => {
  const query = c.req.query();
  const keyword = query.keyword;

  if (keyword) {
    return c.json(bookManager.filter((book) => book.name.includes(keyword)));
  }

  return c.json(bookManager);
});

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

const port = 8080;
console.log(`Server is running on http://localhost:${port}`);

serve({
  fetch: app.fetch,
  port,
});
```

キーワードがもしクエリにあるならキーワードでフィルタリングをしています。
`book.name`にキーワードが含まれるなら残す処理をfilterを使って行っています。

```ts
  const query = c.req.query();
  const keyword = query.keyword;

  if (keyword) {
    return c.json(bookManager.filter((book) => book.name.includes(keyword)));
  }
```

```
$ curl localhost:8080/books?keyword=React
[{"id":1,"name":"React入門","status":"在庫あり"}]
```

1冊だけちゃんと返ってくるようになりました。

## 2-2. 書籍の追加

次は書籍の追加をするためのエンドポイント`POST /books`を作成します。

```ts:backend/src/index.ts
import { serve } from "@hono/node-server";
import { Hono } from "hono";

const app = new Hono();

type BookMangger = {
  id: number;
  name: string;
  status: string;
};

const bookManager: BookMangger[] = [
  { id: 1, name: "React入門", status: "在庫あり" },
  { id: 2, name: "TypeScript入門", status: "貸出中" },
  { id: 3, name: "Next.js入門", status: "返却済" },
];

app.get("/books", async (c) => {
  const query = c.req.query();
  const keyword = query.keyword;

  if (keyword) {
    return c.json(bookManager.filter((book) => book.name.includes(keyword)));
  }

  return c.json(bookManager);
});

// 追加
app.post("/books", async (c) => {
  const body = await c.req.json();
  const name = body.name;

  if (name === "") {
    return c.json({ error: "書籍名は必須です" });
  }

  const newBook = {
    id: bookManager.length + 1,
    name: name,
    status: "在庫あり",
  };

  bookManager.push(newBook);
  return c.json(newBook);
});

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

const port = 8080;
console.log(`Server is running on http://localhost:${port}`);

serve({
  fetch: app.fetch,
  port,
});
```

まずは書籍名をボディで受け取る処理です。もし書籍名がなければ追加ができないのでエラーにします。

```ts
  const body = await c.req.json();
  const name = body.name;

  if (name === "") {
    return c.json({ error: "書籍名は必須です" });
  }
```

次に新しいBookManager型のオブジェクトを作成します。ステータスは追加の場合「在庫あり」にするようにしました。

```ts
  const newBook = {
    id: bookManager.length + 1,
    name: name,
    status: "在庫あり",
  };
```

最後に書籍をテストデータの配列(bookManager)に追加して追加した書籍のデータを返却します。

```tsx
  bookManager.push(newBook);
  return c.json(newBook);
```

```
$ curl -XPOST localhost:8080/books -H "Content-type: application/json" -d '{"name": "新しい書籍"}'
{"id":4,"name":"新しい書籍","status":"在庫あり"}
```

追加した書籍がレスポンスとして返ってきました。一覧を取得してみます。

```
$ curl localhost:8080/books
[{"id":1,"name":"React入門","status":"在庫あり"},{"id":2,"name":"TypeScript入門","status":"貸出中"},{"id":3,"name":"Next.js入門","status":"返却済"},{"id":4,"name":"新しい書籍","status":"在庫あり"}]
```

追加もしっかりできていることが確認できました。

## 2-3. 書籍ステータスの更新

最後にステータスを更新するエンドポイント`PUT /books/:id`です。

```ts:backend/src/index.ts
import { serve } from "@hono/node-server";
import { Hono } from "hono";

const app = new Hono();

type BookMangger = {
  id: number;
  name: string;
  status: string;
};

const bookManager: BookMangger[] = [
  { id: 1, name: "React入門", status: "在庫あり" },
  { id: 2, name: "TypeScript入門", status: "貸出中" },
  { id: 3, name: "Next.js入門", status: "返却済" },
];

app.get("/books", async (c) => {
  const query = c.req.query();
  const keyword = query.keyword;

  if (keyword) {
    return c.json(bookManager.filter((book) => book.name.includes(keyword)));
  }

  return c.json(bookManager);
});

app.post("/books", async (c) => {
  const body = await c.req.json();
  const name = body.name;

  if (name === "") {
    return c.json({ error: "書籍名は必須です" });
  }

  const newBook = {
    id: bookManager.length + 1,
    name: name,
    status: "在庫あり",
  };

  bookManager.push(newBook);
  return c.json(newBook);
});

// 追加
app.put("/books/:id", async (c) => {
  const id = c.req.param("id");
  const body = await c.req.json();
  const status = body.status;

  const book = bookManager.find((book) => book.id === Number(id));

  if (!book) {
    return c.json({ error: "書籍が見つかりません" });
  }

  book.status = status;
  return c.json(book);
});

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

const port = 8080;
console.log(`Server is running on http://localhost:${port}`);

serve({
  fetch: app.fetch,
  port,
});
```

ステータスの更新はURLからIDを取得して、ボディから変更するステータスを受け取ります。

```ts
  const id = c.req.param("id");
  const body = await c.req.json();
  const status = body.status;
```

もしIDに対応する書籍がなければエラーにします。

```ts
  const book = bookManager.find((book) => book.id === Number(id));

  if (!book) {
    return c.json({ error: "書籍が見つかりません" });
  }
```

ステータス更新は見つかった本の`status`に代入するだけで完了です。
配列自体はconstになっているので再代入できませんが、中身は触ることができるのでこのような実装を今回はしています。

```ts
  book.status = status;
  return c.json(book);
```

```
$ curl -XPUT localhost:8080/books/1 -H "Content-type: application/json" -d '{"status": "貸出中"}'
{"id":1,"name":"React入門","status":"貸出中"}

$ curl localhost:8080/books
[{"id":1,"name":"React入門","status":"貸出中"},{"id":2,"name":"TypeScript入門","status":"貸出中"},{"id":3,"name":"Next.js入門","status":"返却済"}]
```

更新ができたことを確認できました。
本来であればステータスが在庫あり/貸出中/返却済のいずれかであることをzodなでバリデーションする必要があるのですが今回はReact19がメインのためスキップします。
こちらは最後の課題にするのでチュートリアルのあとに試してみてください

# 3. 書籍一覧を取得して表示する

先程作成した書籍一覧取得のエンドポイント(GET /books)を使って、テストデータを一覧として表示する実装をしていきます。

このような実装では`useEffect`を利用することが多いですが、今回は`use`を使って非同期処理を実行したいと思います。

```
$ mkdir ./src/domain // frontendの中
$ tocuh book.ts
```

```tsx:frontend/src/domain/book.ts
export class BookManage {
  constructor(public id: number, public name: string, public status: string) {}
}

export type BookManageJson = {
  id: number;
  name: string;
  status: string;
};
```

fetchで受け取るレスポンスの型を`BookManageJson`
私達が開発する世界でのドメインを`BookManage`クラスとしました。

```tsx:frontend/src/App.tsx
import { use } from "react";
import { BookManage, BookManageJson } from "./domain/book";

async function fetchManageBook() {
  await new Promise((resolve) => setTimeout(resolve, 1000));
  const response = await fetch("http://localhost:8080/books");
  const data = (await response.json()) as BookManageJson[];
  return data.map((book) => new BookManage(book.id, book.name, book.status));
}

const fetchManageBookPromise = fetchManageBook();

function App() {
  const initialBooks = use(fetchManageBookPromise);

  return (
    <>
      <div>
        <div>
          <ul>
            {initialBooks.map((book: BookManage) => {
              return <li key={book.id}>{book.name}</li>;
            })}
          </ul>
        </div>
      </div>
    </>
  );
}

export default App;
```

`use`を使ってPromiseを返す関数`fetchManageBookPromise`を実行しています。
こうすることでuseEffectを使わないで書籍一覧を取得することが可能です。

```ts
const initialBooks = use(fetchManageBookPromise);
```

```ts
async function fetchManageBook() {
  await new Promise((resolve) => setTimeout(resolve, 1000));
  const response = await fetch("http://localhost:8080/books");
  const data = (await response.json()) as BookManageJson[];
  return data.map((book) => new BookManage(book.id, book.name, book.status));
}
```

fetchManageBookでは最初に1秒待つ処理をいれています。
これはこのあとSuspendの挙動を確かめるために利用します(useを使うことでサスペンド状態にできるのでローディング画面を表示してみます)

あとはfetchで先ほど作成したAPIを叩いて返却されたJsonを私達の世界のドメインに変換してあげて返すようにしました。

ココでのポイントが`fetchManageBookPromise`を間に挟んでいることです。

```ts
const fetchManageBookPromise = fetchManageBook();

function App() {
  const initialBooks = use(fetchManageBookPromise);
```

本来ならこのようにしても動きそうな感じがしますよね

```ts
function App() {
  const initialBooks = use(fetchManageBook); // 直接関数を呼び出す
```

実はこうするとReactではエラーになってしまいます。

https://qiita.com/Sicut_study/items/92b0874e627b322b97ba

`use`はレンダリングフェーズで作成されるPromiseには対応していないので、レンダリング前(App.tsxが呼び出される前)に用意してあげる必要があるみたいです。
なので`fetchManageBookPromise`で事前にPromiseを用意してあげています。

それでは実行しましょう
`frontend`と`backend`のディレクトリでそれぞれ**npm run dev**をしてサーバーを起動します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/9be692d1-d963-60d9-4bf2-302a2b8215c1.png)

画面をみると

```
Access to fetch at 'http://localhost:8080/books' from origin 'http://localhost:5173' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
```

というエラーがでています。これは **オリジン間リソース共有(CORS)** というHTTPのセキュリティによって発生しているものです。

API側(localhost:8080)がクライアント側(localhost:5173)のアクセスを拒否しているために起こっています。APIをどんなサイトからも叩かれてしまってはセキュリティ的にまずいのでAPIはアクセスを拒否するという仕組みがあると思ってください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/bcda4abf-b8e2-5954-4270-4f1fa8785fd2.png)



ちなみにオリジンとは「プロトコル」+「ホスト名」+「ポート番号」のことを言います。

```
https://example.com:5173
↑      ↑            ↑
プロトコル  ホスト名      ポート番号
```

それではAPIに`http://localhost:5173`を許可する設定を入れましょう

```ts:backend/src/index.ts
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";

const app = new Hono();

type BookMangger = {
  id: number;
  name: string;
  status: string;
};

const bookManager: BookMangger[] = [
  { id: 1, name: "React入門", status: "在庫あり" },
  { id: 2, name: "TypeScript入門", status: "貸出中" },
  { id: 3, name: "Next.js入門", status: "返却済" },
];

// 追加
app.use(
  "/*",
  cors({
    origin: ["http://localhost:5173"],
    allowMethods: ["GET", "POST", "PUT", "DELETE"],
    allowHeaders: ["Content-Type", "Authorization"],
    exposeHeaders: ["Content-Length"],
    maxAge: 3600,
    credentials: true,
  })
);

app.get("/books", async (c) => {
  const query = c.req.query();
  const keyword = query.keyword;

  if (keyword) {
    return c.json(bookManager.filter((book) => book.name.includes(keyword)));
  }

  return c.json(bookManager);
});

app.post("/books", async (c) => {
  const body = await c.req.json();
  const name = body.name;

  if (name === "") {
    return c.json({ error: "書籍名は必須です" });
  }

  const newBook = {
    id: bookManager.length + 1,
    name: name,
    status: "在庫あり",
  };

  bookManager.push(newBook);
  return c.json(newBook);
});

app.put("/books/:id", async (c) => {
  const id = c.req.param("id");
  const body = await c.req.json();
  const status = body.status;

  const book = bookManager.find((book) => book.id === Number(id));

  if (!book) {
    return c.json({ error: "書籍が見つかりません" });
  }

  book.status = status;
  return c.json(book);
});

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

const port = 8080;
console.log(`Server is running on http://localhost:${port}`);

serve({
  fetch: app.fetch,
  port,
});
```

画面を開いてみるとテストデータが表示されるようになりました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/4b741958-8942-95e0-dec5-73d82b8c7169.png)

試しに`Suspense`も利用してみましょう

```tsx:frontend/src/main.tsx
import { StrictMode, Suspense } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <Suspense fallback={<div>Loading...</div>}>
      <App />
    </Suspense>
  </StrictMode>
);
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/d1da6a8b-774b-bd6e-35bf-76ee08d40dc8.png)

`use`を使って取得している間はサスペンド状態となり`fallback`が代わりに表示されるようになりました！

<font color="red">**今回はこのあとのハンズオンの関係でmain.tsxは戻しておきましょう**</font>

```tsx:src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <App />
  </StrictMode>
);
```

# 4. 新しい書籍を追加しよう

まずは`useTransition`と`useState`を利用して書籍追加を実装します。

```frontend/src/App.tsx
import { use, useState, useTransition } from "react";
import { BookManage, BookManageJson } from "./domain/book";

async function fetchManageBook() {
  await new Promise((resolve) => setTimeout(resolve, 1000));
  const response = await fetch("http://localhost:8080/books");
  const data = (await response.json()) as BookManageJson[];
  return data.map((book) => new BookManage(book.id, book.name, book.status));
}

const fetchManageBookPromise = fetchManageBook();

function App() {
  const initialBooks = use(fetchManageBookPromise);
  const [books, setbooks] = useState<BookManage[]>(initialBooks);
  const [bookName, setBookName] = useState<string>("");
  const [isPending, startTransition] = useTransition();

  const handleAddBook = () => {
    startTransition(async () => {
      const response = await fetch("http://localhost:8080/books", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ name: bookName }),
      });
      const data = (await response.json()) as BookManageJson;
      setbooks((prev) => [
        ...prev,
        new BookManage(data.id, data.name, data.status),
      ]);
    });
  };

  return (
    <>
      <div>
        <form>
          <input
            type="text"
            name="bookName"
            placeholder="書籍名"
            value={bookName}
            onChange={(e) => setBookName(e.target.value)}
          />
          <button type="submit" disabled={isPending} onClick={handleAddBook}>
            追加
          </button>
        </form>
        <div>
          <ul>
            {books.map((book: BookManage) => {
              return <li key={book.id}>{book.name}</li>;
            })}
          </ul>
        </div>
      </div>
    </>
  );
}

export default App;
```

2つのステートを用意しました

```tsx
  const [books, setbooks] = useState<BookManage[]>(initialBooks);
  const [bookName, setBookName] = useState<string>("");
```

booksの初期値には`use`で取得したデータを設定しました
bookNameはインプットフォームの内容を保存しておくステート

```ts
  const [isPending, startTransition] = useTransition();
```

今回は`useTransition`で非同期処理をサスペンドします。
`isPending`が`true`なら非同期処理中ということがわかります。
ボタンの`disabled`に設定することで非同期処理中(追加処理中)はボタンを押せないようにします

```tsx
          <button type="submit" disabled={isPending} onClick={handleAddBook}>
            追加
          </button>
```

インプットフォームは`onChange`を設定して入力が変更されるたびに`bookName`ステートを更新しています。

```tsx
          <input
            type="text"
            name="bookName"
            placeholder="書籍名"
            value={bookName}
            onChange={(e) => setBookName(e.target.value)}
          />
```

追加ボタンには`onClick`を設定してクリックしたら`handleAddBook`が実行されます。

```tsx
          <button type="submit" disabled={isPending} onClick={handleAddBook}>
            追加
          </button>
```

`handleAddBook`の中でuseTransitionから返ってくる`startTransition`を使います。
中では非同期処理を実行します。非同期処理が終わるまでisPendingはtrueになります。

```tsx  
  const handleAddBook = () => {
    startTransition(async () => {
      const response = await fetch("http://localhost:8080/books", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ name: bookName }),
      });
      const data = (await response.json()) as BookManageJson;
      setbooks((prev) => [
        ...prev,
        new BookManage(data.id, data.name, data.status),
      ]);
    });
  };
```

APIを叩いたあとにbookステートに新しい書籍を追加しています。
ここで新しいフックである`useActionState`と`form`を使ってよりスマートに書いてみます。

```tsx:frontend/src/domain/book.ts
export class BookManage {
  constructor(public id: number, public name: string, public status: string) {}
}

export type BookManageJson = {
  id: number;
  name: string;
  status: string;
};

// 追加
export type BookState = {
  allBooks: BookManage[];
};
```

```tsx:backend/src/App.tsx
import { use, useActionState, useRef } from "react";
import { BookManage, BookManageJson, BookState } from "./domain/book";

async function fetchManageBook() {
  await new Promise((resolve) => setTimeout(resolve, 1000));
  const response = await fetch("http://localhost:8080/books");
  const data = (await response.json()) as BookManageJson[];
  return data.map((book) => new BookManage(book.id, book.name, book.status));
}

const fetchManageBookPromise = fetchManageBook();

function App() {
  const initialBooks = use(fetchManageBookPromise);
  const addFormRef = useRef<HTMLFormElement>(null);
  const [bookState, updateBookState, isPending] = useActionState(
    async (
      prevState: BookState | undefined,
      formData: FormData
    ): Promise<BookState> => {
      if (!prevState) {
        throw new Error("Invalid state");
      }

      const name = formData.get("bookName") as string;

      if (!name) {
        throw new Error("Book name is required");
      }

      const response = await fetch("http://localhost:8080/books", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ name }),
      });

      if (!response.ok) {
        throw new Error("Failed to add book");
      }

      const newBook = await response.json();
      addFormRef.current?.reset();

      return {
        allBooks: [...prevState.allBooks, newBook],
      };
    },
    {
      allBooks: initialBooks,
    }
  );

  return (
    <>
      <div>
        <form action={updateBookState} ref={addFormRef}>
          <input type="text" name="bookName" placeholder="書籍名" />
          <button type="submit" disabled={isPending}>
            追加
          </button>
        </form>
        <div>
          <ul>
            {bookState.allBooks.map((book: BookManage) => {
              return <li key={book.id}>{book.name}</li>;
            })}
          </ul>
        </div>
      </div>
    </>
  );
}

export default App;
```

今回は`useActionState`を使うのでフォームのアクションを活用します。
addFormRefは追加したあとにフォームの入力をクリアするのに使います。`useRef`を使うことでDOMを直接操作ができインプットフォームをクリアしても再レンダリング(画面の更新)が走りません。

```tsx
  const addFormRef = useRef<HTMLFormElement>(null);
  const [bookState, updateBookState, isPending] = useActionState(

  省略
  
<form action={updateBookState} ref={addFormRef}>
```

`useActionState`はupdateBookStateというアクションを提供しており、以下の処理を行います。追加ボタンがおされると実行されます。

```tsx
    async (
      prevState: BookState | undefined,
      formData: FormData
    ): Promise<BookState> => {
      if (!prevState) {
        throw new Error("Invalid state");
      }

      const name = formData.get("bookName") as string;

      if (!name) {
        throw new Error("Book name is required");
      }

      const response = await fetch("http://localhost:8080/books", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ name }),
      });

      if (!response.ok) {
        throw new Error("Failed to add book");
      }

      const newBook = await response.json();
      addFormRef.current?.reset();

      return {
        allBooks: [...prevState.allBooks, newBook],
      };
    },
    {
      allBooks: initialBooks,
    }
```

この関数は引数と戻り値はこのようになっています。

```tsx
    async (
      prevState: BookState | undefined,
      formData: FormData
    ): Promise<BookState> => {
      // 追加の処理
    }
```

`prevState`には今現在の値(bookState)が渡ってきます。
これは`BookState`型として定義をしました。

```ts
export type BookState = {
  allBooks: BookManage[];
};
```

updateBooksStateは返却値としてBookState型の値を返すので、その返却された値でステート(bookState)が更新されることになります。

```tsx
      if (!prevState) {
        throw new Error("Invalid state");
      }

      const name = formData.get("bookName") as string;

      if (!name) {
        throw new Error("Book name is required");
      }
```

`prevState`がundefinedの可能性があるのでその場合はエラーを返すようにしました。
こうすることでこの先のコードではprevStateが存在することが保証されます。

formの新機能でアクションを利用する場合は`formData.get`でインプットフォームの内容を取れるようになったので入力した値を取り出しています。なければ追加ができないのでエラーを返しました。

```tsx
<input type="text" name="bookName" placeholder="書籍名" />
```

そこからは先程と同じでAPIを叩いて書籍追加を行います。
そのあとフォームに入力されている値をクリアしてステート更新を行います。

```tsx
      addFormRef.current?.reset();

      return {
        allBooks: [...prevState.allBooks, newBook],
      };
```

前のステートに新しい書籍を追加して配列を作りallBooksとして返却します。

useActionStateの第2引数はbookStateの初期値です。
`use`で取得したデータを初期値に設定しています。

```tsx
    {
      allBooks: initialBooks,
    }
```

書籍の表示も`bookState.allBooks`を使うようにしています

```tsx
            {bookState.allBooks.map((book: BookManage) => {
              return <li key={book.id}>{book.name}</li>;
            })}
```

画面を確認すると同じ挙動で実装することができました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/905f453f-98ca-e68a-7ea5-b2df48eae212.png)

App.tsxにコードが増えたので最後にリファクタリングをします。

```
$ touch src/bookActions.ts // frontendフォルダ
```

```ts:frontend/src/bookActions.ts
import { BookState } from "./domain/book";

export const handleAddBook = async (
  prevState: BookState,
  formData: FormData
): Promise<BookState> => {

  const name = formData.get("bookName") as string;

  if (!name) {
    throw new Error("Book name is required");
  }

  const response = await fetch("http://localhost:8080/books", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ name }),
  });

  if (!response.ok) {
    throw new Error("Failed to add book");
  }

  const newBook = await response.json();

  return {
    ...prevState,
    allBooks: [...prevState.allBooks, newBook],
  };
};
```

```tsx:/frontend/src/App.tsx
import { use, useActionState, useRef } from "react";
import { BookManage, BookManageJson, BookState } from "./domain/book";
import { handleAddBook } from "./bookActions";

async function fetchManageBook() {
  await new Promise((resolve) => setTimeout(resolve, 1000));
  const response = await fetch("http://localhost:8080/books");
  const data = (await response.json()) as BookManageJson[];
  return data.map((book) => new BookManage(book.id, book.name, book.status));
}

const fetchManageBookPromise = fetchManageBook();

function App() {
  const initialBooks = use(fetchManageBookPromise);
  const addFormRef = useRef<HTMLFormElement>(null);
  const [bookState, updateBookState, isPending] = useActionState(
    async (
      prevState: BookState | undefined,
      formData: FormData
    ): Promise<BookState> => {
      if (!prevState) {
        throw new Error("Invalid state");
      }

      return handleAddBook(prevState, formData);
    },
    {
      allBooks: initialBooks,
    }
  );

  return (
    <>
      <div>
        <form action={updateBookState} ref={addFormRef}>
          <input type="text" name="bookName" placeholder="書籍名" />
          <button type="submit" disabled={isPending}>
            追加
          </button>
        </form>
        <div>
          <ul>
            {bookState.allBooks.map((book: BookManage) => {
              return <li key={book.id}>{book.name}</li>;
            })}
          </ul>
        </div>
      </div>
    </>
  );
}

export default App;
```

アクション部分を別ファイルに移動しました！

# 5. 書籍を検索できるようにする

同じ要領で検索を実装します。

```frontend/src/domain/book.ts
export class BookManage {
  constructor(public id: number, public name: string, public status: string) {}
}

export type BookManageJson = {
  id: number;
  name: string;
  status: string;
};

// 修正
export type BookState = {
  allBooks: BookManage[];
  filteredBooks: BookManage[] | null;
  keyword: string;
};
```

BookStateに検索キーワードと検索でヒットした書籍を保存しておくためステートの項目を増やしました。
filteredBooksは最初値がないのでnullも許容しています

```ts:frontend/src/bookActions.ts
import { BookManage, BookManageJson, BookState } from "./domain/book";

export const handleAddBook = async (
  prevState: BookState,
  formData: FormData
): Promise<BookState> => {
  const name = formData.get("bookName") as string;

  if (!name) {
    throw new Error("Book name is required");
  }

  const response = await fetch("http://localhost:8080/books", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ name }),
  });

  if (!response.ok) {
    throw new Error("Failed to add book");
  }

  const newBook = await response.json();

  return {
    ...prevState,
    allBooks: [...prevState.allBooks, newBook],
    filteredBooks: prevState.filteredBooks
      ? [...prevState.filteredBooks, newBook]
      : null,
  };
};

export const handleSearchBooks = async (
  prevState: BookState,
  formData: FormData
): Promise<BookState> => {
  const keyword = formData.get("keyword") as string;

  if (!keyword) {
    throw new Error("Keyword is required");
  }

  const response = await fetch(
    `http://localhost:8080/books?keyword=${keyword}`
  );
  const data = (await response.json()) as BookManageJson[];
  const filteredBooks = data.map(
    (book) => new BookManage(book.id, book.name, book.status)
  );

  return {
    ...prevState,
    filteredBooks,
    keyword,
  };
};
```

handleSearchBooksはキーワードが必須なのでキーワードがない場合はエラーを出します

```ts
  const keyword = formData.get("keyword") as string;

  if (!keyword) {
    throw new Error("Keyword is required");
  }
```

あとはfetchManageBookと同じ要領でAPIを叩いてからステートの`filteredBooks`につめて返します。

```tsx
  const response = await fetch(
    `http://localhost:8080/books?keyword=${keyword}`
  );
  const data = (await response.json()) as BookManageJson[];
  const filteredBooks = data.map(
    (book) => new BookManage(book.id, book.name, book.status)
  );

  return {
    ...prevState,
    filteredBooks,
    keyword,
  };
```

合わせて追加も検索に対応しました！

```tsx
  const newBook = await response.json();

  return {
    ...prevState,
    allBooks: [...prevState.allBooks, newBook],
    filteredBooks: prevState.filteredBooks
      ? [...prevState.filteredBooks, newBook]
      : null,
  };
```

検索をしたあとに追加をする場合filteredBooksにも新しい書籍を追加するようにしています。

次に画面に検索フォームを追加します。

```tsx/frontend/src/App.tsx
import { use, useActionState, useRef } from "react";
import { BookManage, BookManageJson, BookState } from "./domain/book";
import { handleAddBook, handleSearchBooks } from "./bookActions";

async function fetchManageBook() {
  await new Promise((resolve) => setTimeout(resolve, 1000));
  const response = await fetch("http://localhost:8080/books");
  const data = (await response.json()) as BookManageJson[];
  return data.map((book) => new BookManage(book.id, book.name, book.status));
}

const fetchManageBookPromise = fetchManageBook();

function App() {
  const initialBooks = use(fetchManageBookPromise);
  const addFormRef = useRef<HTMLFormElement>(null);
  const searchFormRef = useRef<HTMLFormElement>(null); // 追加
  const [bookState, updateBookState, isPending] = useActionState(
    async (
      prevState: BookState | undefined,
      formData: FormData
    ): Promise<BookState> => {
      if (!prevState) {
        throw new Error("Invalid state");
      }

      const action = formData.get("formType") as string;

      // 修正
      const actionHandlers = {
        add: () => handleAddBook(prevState, formData),
        search: () => handleSearchBooks(prevState, formData),
      } as const;

      if (action !== "add" && action !== "search") {
        throw new Error(`Invalid action: ${action}`);
      }

      return actionHandlers[action]();
    },
    {
      allBooks: initialBooks,
      filteredBooks: null,
      keyword: "",
    }
  );

  // 追加
  const books = bookState.filteredBooks || bookState.allBooks;

  return (
    <>
      <div>
        <form action={updateBookState} ref={addFormRef}>
          <input type="hidden" name="formType" value="add" />
          <input type="text" name="bookName" placeholder="書籍名" />
          <button type="submit" disabled={isPending}>
            追加
          </button>
        </form>
        {/* 追加 */}
        <form ref={searchFormRef} action={updateBookState}>
          <input type="hidden" name="formType" value="search" />
          <input type="text" name="keyword" placeholder="書籍名で検索" />
          <button type="submit" disabled={isPending}>
            検索
          </button>
        </form>
        <div>
          <ul>
          　{/* 修正 */}
            {books?.map((book: BookManage) => {
              return <li key={book.id}>{book.name}</li>;
            })}
          </ul>
        </div>
      </div>
    </>
  );
}

export default App;
```

まずはインプットフォームを追加しました。検索ボタンをおすと追加と同じ`updateBookState`を実行します。

```tsx
        <form ref={searchFormRef} action={updateBookState}>
          <input type="hidden" name="formType" value="search" />
          <input type="text" name="keyword" placeholder="書籍名で検索" />
          <button type="submit" disabled={isPending}>
            検索
          </button>
        </form>
```

追加と検索で同じアクションを呼び出しますが、処理は異なります。押されたボタンが「追加」か「検索」かを判断するために見えないインプットフォームを用意してアクションの種類を値として入れています。
こうすることでアクションで`form.get("formType")`とすることで判断することができます。

```tsx
  const [bookState, updateBookState, isPending] = useActionState(
    async (
      prevState: BookState | undefined,
      formData: FormData
    ): Promise<BookState> => {
      if (!prevState) {
        throw new Error("Invalid state");
      }

      const action = formData.get("formType") as string;

      // 修正
      const actionHandlers = {
        add: () => handleAddBook(prevState, formData),
        search: () => handleSearchBooks(prevState, formData),
      } as const;

      if (action !== "add" && action !== "search") {
        throw new Error(`Invalid action: ${action}`);
      }

      return actionHandlers[action]();
    },
    {
      allBooks: initialBooks,
      filteredBooks: null,
      keyword: "",
    }
  );
```

アクションを判断して対応する`bookActions`を呼び出しています。
もしアクションがない場合はエラーにして返します。

```tsx      
      const action = formData.get("formType") as string;
      const actionHandlers = {
        add: () => handleAddBook(prevState, formData),
        search: () => handleSearchBooks(prevState, formData),
      } as const;

      if (action !== "add" && action !== "search") {
        throw new Error(`Invalid action: ${action}`);
      }

      return actionHandlers[action]();
```

表示する書籍はもし`filteredBooks`があるなら優先して、なければ`allBooks`を返すようにしました。
こうすることで初期表示は`use`で取得したすべての書籍で検索するとヒットした書籍を表示できます。

```tsx
  const books = bookState.filteredBooks || bookState.allBooks;
　（省略）

          <div>
          <ul>
            {books?.map((book: BookManage) => {
              return <li key={book.id}>{book.name}</li>;
            })}
          </ul>
        </div>

```

検索フォームに「React」と入れて検索ボタンを押すと「React入門」のみがヒットします。
検索ができるようになりました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/6aab4c07-a1df-c492-31d1-e482a5255e00.png)

# 6. 書籍のステータスを更新しよう！

書籍にはステータス『在庫あり』『貸出中』『返却済』を付与することができます。
追加や検索と同じ考え方で実装していきましょう

```tsx:frontend/src/bookActions.ts
import { BookManage, BookManageJson, BookState } from "./domain/book";

export const handleAddBook = async (
  prevState: BookState,
  formData: FormData
): Promise<BookState> => {
  const name = formData.get("bookName") as string;

  if (!name) {
    throw new Error("Book name is required");
  }

  const response = await fetch("http://localhost:8080/books", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ name }),
  });

  if (!response.ok) {
    throw new Error("Failed to add book");
  }

  const newBook = await response.json();

  return {
    ...prevState,
    allBooks: [...prevState.allBooks, newBook],
    filteredBooks: prevState.filteredBooks
      ? [...prevState.filteredBooks, newBook]
      : null,
  };
};

export const handleSearchBooks = async (
  prevState: BookState,
  formData: FormData
): Promise<BookState> => {
  const keyword = formData.get("keyword") as string;

  if (!keyword) {
    throw new Error("Keyword is required");
  }

  const response = await fetch(
    `http://localhost:8080/books?keyword=${keyword}`
  );
  const data = (await response.json()) as BookManageJson[];
  const filteredBooks = data.map(
    (book) => new BookManage(book.id, book.name, book.status)
  );

  return {
    ...prevState,
    filteredBooks,
    keyword,
  };
};

// 追加
export const handleUpdateBook = async (
  prevState: BookState,
  formData: FormData
): Promise<BookState> => {
  const id = Number(formData.get("id"));
  const status = formData.get("status") as string;

  const response = await fetch(`http://localhost:8080/books/${id}`, {
    method: "PUT",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ status }),
  });

  if (!response.ok) {
    throw new Error("Failed to update book");
  }

  const updatedBook = await response.json();
  const updatedBooks = prevState.allBooks.map((book) =>
    book.id === id ? updatedBook : book
  );
  const filteredBooks = prevState.filteredBooks?.map((book) =>
    book.id === id ? updatedBook : book
  );

  return {
    ...prevState,
    allBooks: updatedBooks,
    filteredBooks: filteredBooks || null,
  };
};
```

アップデートするためのアクションを用意しています。

```ts
export const handleUpdateBook = async (
  prevState: BookState,
  formData: FormData
): Promise<BookState> => {
  const id = Number(formData.get("id"));
  const status = formData.get("status") as string;

  const response = await fetch(`http://localhost:8080/books/${id}`, {
    method: "PUT",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ status }),
  });

  if (!response.ok) {
    throw new Error("Failed to update book");
  }

  const updatedBook = await response.json();
  const updatedBooks = prevState.allBooks.map((book) =>
    book.id === id ? updatedBook : book
  );
  const filteredBooks = prevState.filteredBooks?.map((book) =>
    book.id === id ? updatedBook : book
  );

  return {
    ...prevState,
    allBooks: updatedBooks,
    filteredBooks: filteredBooks || null,
  };
};
```

いままで実装したものとあまり変わりはありませんが、今回はAPIを叩いたあとにステートの該当する書籍のステータスを直接変更しています。

```ts
  const updatedBooks = prevState.allBooks.map((book) =>
    book.id === id ? updatedBook : book
  );
  const filteredBooks = prevState.filteredBooks?.map((book) =>
    book.id === id ? updatedBook : book
  );
```

画面もこれまでは書籍名のみでしたがステータスを表示するようにしましょう

```tsx:frontend/src/App.tsx
import { use, useActionState, useRef } from "react";
import { BookManage, BookManageJson, BookState } from "./domain/book";
import {
  handleAddBook,
  handleSearchBooks,
  handleUpdateBook,
} from "./bookActions";

async function fetchManageBook() {
  await new Promise((resolve) => setTimeout(resolve, 1000));
  const response = await fetch("http://localhost:8080/books");
  const data = (await response.json()) as BookManageJson[];
  return data.map((book) => new BookManage(book.id, book.name, book.status));
}

const fetchManageBookPromise = fetchManageBook();

function App() {
  const initialBooks = use(fetchManageBookPromise);
  const addFormRef = useRef<HTMLFormElement>(null);
  const searchFormRef = useRef<HTMLFormElement>(null);
  const [bookState, updateBookState, isPending] = useActionState(
    async (
      prevState: BookState | undefined,
      formData: FormData
    ): Promise<BookState> => {
      if (!prevState) {
        throw new Error("Invalid state");
      }

      const action = formData.get("formType") as string;

     　// 修正
      const actionHandlers = {
        add: () => handleAddBook(prevState, formData),
        search: () => handleSearchBooks(prevState, formData),
        update: () => handleUpdateBook(prevState, formData),
      } as const;

      // しゅうｓ
      if (action !== "add" && action !== "search" && action !== "update") {
        throw new Error(`Invalid action: ${action}`);
      }

      return actionHandlers[action]();
    },
    {
      allBooks: initialBooks,
      filteredBooks: null,
      keyword: "",
    }
  );

  const books = bookState.filteredBooks || bookState.allBooks;

  return (
    <>
      <div>
        <form action={updateBookState} ref={addFormRef}>
          <input type="hidden" name="formType" value="add" />
          <input type="text" name="bookName" placeholder="書籍名" />
          <button type="submit" disabled={isPending}>
            追加
          </button>
        </form>
        <form ref={searchFormRef} action={updateBookState}>
          <input type="hidden" name="formType" value="search" />
          <input type="text" name="keyword" placeholder="書籍名で検索" />
          <button type="submit" disabled={isPending}>
            検索
          </button>
        </form>
        <div>
          <ul>
            {/* 追加 */}
            {books.map((book: BookManage) => {
              const bookStatus = book.status;
              return (
                <li key={book.id}>
                  {book.name}
                  <form action={updateBookState}>
                    <input type="hidden" name="formType" value="update" />
                    <input type="hidden" name="id" value={book.id} />
                    <select
                      key={`select-${book.id}-${bookStatus}`}
                      name="status"
                      defaultValue={bookStatus}
                      onChange={(e) => {
                        e.target.form?.requestSubmit();
                      }}
                    >
                      <option value="在庫あり">在庫あり</option>
                      <option value="貸出中">貸出中</option>
                      <option value="返却済">返却済</option>
                    </select>
                  </form>
                </li>
              );
            })}
          </ul>
        </div>
      </div>
    </>
  );
}

export default App;
```

`actionHandlers`に`update`を追加しました。
ハンドラーがあるかないかのif分岐にも`action !== "update"`を追加し忘れないようにしてください

```ts
      const actionHandlers = {
        add: () => handleAddBook(prevState, formData),
        search: () => handleSearchBooks(prevState, formData),
        update: () => handleUpdateBook(prevState, formData),
      } as const;

      if (action !== "add" && action !== "search" && action !== "update") {
        throw new Error(`Invalid action: ${action}`);
      }
```

画面の部分もこれまでと同じ要領でステータスのセレクトボックスを用意しました。

```ts
            {books.map((book: BookManage) => {
              const bookStatus = book.status;
              return (
                <li key={book.id}>
                  {book.name}
                  <form action={updateBookState}>
                    <input type="hidden" name="formType" value="update" />
                    <input type="hidden" name="id" value={book.id} />
                    <select
                      key={`select-${book.id}-${bookStatus}`}
                      name="status"
                      defaultValue={bookStatus}
                      onChange={(e) => {
                        e.target.form?.requestSubmit();
                      }}
                    >
                      <option value="在庫あり">在庫あり</option>
                      <option value="貸出中">貸出中</option>
                      <option value="返却済">返却済</option>
                    </select>
                  </form>
                </li>
              );
            })}
```

ここでのポイントはまずselectに`key`をふることです。

```ts
                    <select
                      key={`select-${book.id}-${bookStatus}`}
                      name="status"
                      defaultValue={bookStatus}
                      onChange={(e) => {
                        e.target.form?.requestSubmit();
                      }}
                    >
```

キーをふらないとステータスを変更してもセレクトボックスの値が変わらない(変更前のステータスが選択されている)問題が発生します。
ステータスが変更してもセレクトボックスには変更されたことがdefaultValueが変わったことはわかりません。
そこでキーを設定することでステータスが変わるとキーの値`select-${book.id}-${bookStatus}`が変更されて再レンダリングが走ることで`defaultValue`も更新されます。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/0f9e80a6-1e62-b765-7622-e445182a8779.png)

ステータス更新ができるようになりました！

# 7. 楽観的更新でユーザー体験を向上しよう

最後に`useOptimistic`を使って楽観的更新をします。
いいね機能などは楽観的更新を利用することが多いです。いいねを押すとすぐに色がついていいねを押したことがわかりますが、実際の処理は押した直後に行われるのではなくUIを更新したあとに裏側で行われています。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/7a56695b-a986-fcab-652a-7c4c4a05846e.png)

こうすることでユーザーに即時フィードバックができユーザー体験の向上につながります。
今回は書籍ステータスに楽観的更新を利用してみましょう

```tsx:frontend/src/App.tsx
import { use, useActionState, useOptimistic, useRef } from "react";
import { BookManage, BookManageJson, BookState } from "./domain/book";
import {
  handleAddBook,
  handleSearchBooks,
  handleUpdateBook,
} from "./bookActions";

async function fetchManageBook() {
  await new Promise((resolve) => setTimeout(resolve, 1000));
  const response = await fetch("http://localhost:8080/books");
  const data = (await response.json()) as BookManageJson[];
  return data.map((book) => new BookManage(book.id, book.name, book.status));
}

const fetchManageBookPromise = fetchManageBook();

function App() {
  const initialBooks = use(fetchManageBookPromise);
  const addFormRef = useRef<HTMLFormElement>(null);
  const searchFormRef = useRef<HTMLFormElement>(null);
  const [bookState, updateBookState, isPending] = useActionState(
    async (
      prevState: BookState | undefined,
      formData: FormData
    ): Promise<BookState> => {
      if (!prevState) {
        throw new Error("Invalid state");
      }

      const action = formData.get("formType") as string;

　　　 // 修正
      const actionHandlers = {
        add: () => handleAddBook(prevState, formData, updateOptimisticBooks),
        search: () => handleSearchBooks(prevState, formData),
        update: () => handleUpdateBook(prevState, formData, updateOptimisticBooks),
      } as const;

      if (action !== "add" && action !== "search" && action !== "update") {
        throw new Error(`Invalid action: ${action}`);
      }

      return actionHandlers[action]();
    },
    {
      allBooks: initialBooks,
      filteredBooks: null,
      keyword: "",
    }
  );

　// 修正
  const [optimisticBooks, updateOptimisticBooks] = useOptimistic<BookManage[]>(
    bookState?.filteredBooks ?? bookState?.allBooks ?? []
  );

  return (
    <>
      <div>
        <form action={updateBookState} ref={addFormRef}>
          <input type="hidden" name="formType" value="add" />
          <input type="text" name="bookName" placeholder="書籍名" />
          <button type="submit" disabled={isPending}>
            追加
          </button>
        </form>
        <form ref={searchFormRef} action={updateBookState}>
          <input type="hidden" name="formType" value="search" />
          <input type="text" name="keyword" placeholder="書籍名で検索" />
          <button type="submit" disabled={isPending}>
            検索
          </button>
        </form>
        <div>
          <ul>
            {/* 修正 */}
            {optimisticBooks.map((book: BookManage) => {
              const bookStatus = book.status;
              return (
                <li key={book.id}>
                  {book.name}
                  <form action={updateBookState}>
                    <input type="hidden" name="formType" value="update" />
                    <input type="hidden" name="id" value={book.id} />
                    <select
                      key={`select-${book.id}-${bookStatus}`}
                      name="status"
                      defaultValue={bookStatus}
                      onChange={(e) => {
                        e.target.form?.requestSubmit();
                      }}
                    >
                      <option value="在庫あり">在庫あり</option>
                      <option value="貸出中">貸出中</option>
                      <option value="返却済">返却済</option>
                    </select>
                  </form>
                </li>
              );
            })}
          </ul>
        </div>
      </div>
    </>
  );
}

export default App;
```

```tsx:frontend/src/bookActions.ts
import { BookManage, BookManageJson, BookState } from "./domain/book";

// 修正
export const handleAddBook = async (
  prevState: BookState,
  formData: FormData,
  updateOptimisticBooks: (prevState: BookManage[]) => void
): Promise<BookState> => {
  const name = formData.get("bookName") as string;

  if (!name) {
    throw new Error("Book name is required");
  }

  // 追加
  updateOptimisticBooks([
    ...prevState.allBooks,
    new BookManage(0, name, "在庫あり"),
  ]);

  const response = await fetch("http://localhost:8080/books", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ name }),
  });

  if (!response.ok) {
    throw new Error("Failed to add book");
  }

  const newBook = await response.json();
  return {
    ...prevState,
    allBooks: [...prevState.allBooks, newBook],
    filteredBooks: prevState.filteredBooks
      ? [...prevState.filteredBooks, newBook]
      : null,
  };
};

export const handleSearchBooks = async (
  prevState: BookState,
  formData: FormData
): Promise<BookState> => {
  const keyword = formData.get("keyword") as string;

  if (!keyword) {
    throw new Error("Keyword is required");
  }

  const response = await fetch(
    `http://localhost:8080/books?keyword=${keyword}`
  );
  const data = (await response.json()) as BookManageJson[];
  const filteredBooks = data.map(
    (book) => new BookManage(book.id, book.name, book.status)
  );

  return {
    ...prevState,
    filteredBooks,
    keyword,
  };
};

// 修正
export const handleUpdateBook = async (
  prevState: BookState,
  formData: FormData,
  updateOptimisticBooks: (prevState: BookManage[]) => void
): Promise<BookState> => {
  const id = Number(formData.get("id"));
  const status = formData.get("status") as string;

  const response = await fetch(`http://localhost:8080/books/${id}`, {
    method: "PUT",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ status }),
  });

  if (!response.ok) {
    throw new Error("Failed to update book");
  }

　// 追加
  updateOptimisticBooks(
    prevState.allBooks.map((book) =>
      book.id === id ? { ...book, status } : book
    )
  );

  const updatedBook = await response.json();
  const updatedBooks = prevState.allBooks.map((book) =>
    book.id === id ? updatedBook : book
  );
  const filteredBooks = prevState.filteredBooks?.map((book) =>
    book.id === id ? updatedBook : book
  );

  return {
    ...prevState,
    allBooks: updatedBooks,
    filteredBooks: filteredBooks || null,
  };
};
```

こうすることでAPIを叩く前にUIを更新できるようになりました。
APIは高速で叩けてしまいますが、5秒程度のスリープを入れると違いがわかりやすいです。

```tsx
export const handleAddBook = async (
  prevState: BookState,
  formData: FormData,
  updateOptimisticBooks: (prevState: BookManage[]) => void
): Promise<BookState> => {
  const name = formData.get("bookName") as string;

  if (!name) {
    throw new Error("Book name is required");
  }

  updateOptimisticBooks([
    ...prevState.allBooks,
    new BookManage(0, name, "在庫あり"),
  ]);

  // 追加するまでに1秒待つ
  await new Promise((resolve) => setTimeout(resolve, 1000));
  const response = await fetch("http://localhost:8080/books", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ name }),
  });

  if (!response.ok) {
    throw new Error("Failed to add book");
  }

  const newBook = await response.json();
  return {
    ...prevState,
    allBooks: [...prevState.allBooks, newBook],
    filteredBooks: prevState.filteredBooks
      ? [...prevState.filteredBooks, newBook]
      : null,
  };
};
```

本来なら10秒経つまで書籍追加はされませんが、楽観的更新によりUIに即時反映されます。

# 8. デザインを適応する

最後にTailwindCSSとframer-motionでデザインを当てましょう。

```
$ npm i framer-motion
```

```tsx:frontend/src/App.tsx
import { use, useActionState, useOptimistic, useRef } from "react";
import { BookManage, BookManageJson, BookState } from "./domain/book";
import {
  handleAddBook,
  handleSearchBooks,
  handleUpdateBook,
} from "./bookActions";
import { motion, AnimatePresence } from "framer-motion";

async function fetchManageBook() {
  await new Promise((resolve) => setTimeout(resolve, 1000));
  const response = await fetch("http://localhost:8080/books");
  const data = (await response.json()) as BookManageJson[];
  return data.map((book) => new BookManage(book.id, book.name, book.status));
}

const fetchManageBookPromise = fetchManageBook();

function App() {
  const initialBooks = use(fetchManageBookPromise);
  const addFormRef = useRef<HTMLFormElement>(null);
  const searchFormRef = useRef<HTMLFormElement>(null);
  const [bookState, updateBookState, isPending] = useActionState(
    async (
      prevState: BookState | undefined,
      formData: FormData
    ): Promise<BookState> => {
      if (!prevState) {
        throw new Error("Invalid state");
      }

      const action = formData.get("formType") as string;

      const actionHandlers = {
        add: () => handleAddBook(prevState, formData, updateOptimisticBooks),
        search: () => handleSearchBooks(prevState, formData),
        update: () => handleUpdateBook(prevState, formData, updateOptimisticBooks),
      } as const;

      if (action !== "add" && action !== "search" && action !== "update") {
        throw new Error(`Invalid action: ${action}`);
      }

      return actionHandlers[action]();
    },
    {
      allBooks: initialBooks,
      filteredBooks: null,
      keyword: "",
    }
  );

  const [optimisticBooks, updateOptimisticBooks] = useOptimistic<BookManage[]>(
    bookState?.filteredBooks ?? bookState?.allBooks ?? []
  );

  return (
    <>
      <div className="min-h-screen bg-gradient-to-br from-blue-100 to-purple-100 p-8">
        <motion.div
          initial={{ opacity: 0, y: 20 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.5 }}
          className="max-w-7xl mx-auto"
        >
          <motion.h1
            initial={{ opacity: 0, y: -20 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ duration: 0.5, delay: 0.2 }}
            className="text-4xl font-bold text-gray-800 mb-12 text-center"
          >
            Book Manager
          </motion.h1>

          <div className="grid md:grid-cols-2 gap-6 mb-12">
            {/* Add Book Form */}
            <motion.div
              initial={{ opacity: 0, x: -20 }}
              animate={{ opacity: 1, x: 0 }}
              transition={{ duration: 0.5, delay: 0.3 }}
              className="bg-white rounded-xl shadow-lg p-6"
            >
              <form
                ref={addFormRef}
                action={updateBookState}
                className="space-y-4"
              >
                <input type="hidden" name="formType" value="add" />
                <motion.input
                  whileFocus={{ scale: 1.02 }}
                  transition={{ duration: 0.2 }}
                  type="text"
                  name="bookName"
                  placeholder="書籍名"
                  className="w-full px-4 py-3 text-gray-700 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition duration-200"
                />
                <motion.button
                  whileHover={{ scale: 1.02 }}
                  whileTap={{ scale: 0.98 }}
                  type="submit"
                  disabled={isPending}
                  className="w-full px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center justify-center gap-2"
                >
                  {isPending ? (
                    <div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
                  ) : null}
                  追加
                </motion.button>
              </form>
            </motion.div>
            <motion.div
              initial={{ opacity: 0, x: 20 }}
              animate={{ opacity: 1, x: 0 }}
              transition={{ duration: 0.5, delay: 0.4 }}
              className="bg-white rounded-xl shadow-lg p-6"
            >
              <form
                ref={searchFormRef}
                action={updateBookState}
                className="space-y-4"
              >
                <input type="hidden" name="formType" value="search" />
                <motion.input
                  whileFocus={{ scale: 1.02 }}
                  transition={{ duration: 0.2 }}
                  type="text"
                  name="keyword"
                  placeholder="書籍名で検索"
                  className="w-full px-4 py-3 text-gray-700 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent transition duration-200"
                />
                <motion.button
                  whileHover={{ scale: 1.02 }}
                  whileTap={{ scale: 0.98 }}
                  type="submit"
                  disabled={isPending}
                  className="w-full px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center justify-center gap-2"
                >
                  {isPending ? (
                    <div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
                  ) : null}
                  検索
                </motion.button>
              </form>
            </motion.div>
          </div>
          <AnimatePresence>
            <motion.div
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              transition={{ duration: 0.5, delay: 0.5 }}
              className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
            >
              {optimisticBooks.map((book: BookManage) => (
                <div
                  key={book.id}
                  className="bg-white rounded-xl overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300"
                >
                  <div className="relative aspect-[2/3] overflow-hidden">
                    <img
                      src={`https://picsum.photos/300/450?random=${book.id}`}
                      alt={book.name}
                      className="object-cover w-full h-full hover:scale-105 transition-transform duration-300"
                    />
                    <div className="absolute top-2 right-2">
                      <span
                        className={`px-3 py-1 text-sm font-medium text-white rounded-full shadow-md ${
                          book.status === "在庫あり"
                            ? "bg-green-500"
                            : book.status === "貸出中"
                            ? "bg-yellow-500"
                            : "bg-blue-500"
                        }`}
                      >
                        {book.status}
                      </span>
                    </div>
                  </div>
                  <div className="p-5 space-y-4">
                    <h3 className="font-bold text-xl text-gray-800 line-clamp-2">
                      {book.name}
                    </h3>
                    {book.status}
                    <form action={updateBookState}>
                      <input type="hidden" name="formType" value="update" />
                      <input type="hidden" name="id" value={book.id} />
                      <select
                        key={`select-${book.id}-${book.status}`}
                        name="status"
                        defaultValue={book.status}
                        onChange={(e) => {
                          e.target.form?.requestSubmit();
                        }}
                        className="w-full px-3 py-2 text-gray-700 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition duration-200"
                      >
                        <option value="在庫あり">在庫あり</option>
                        <option value="貸出中">貸出中</option>
                        <option value="返却済">返却済</option>
                      </select>
                    </form>
                  </div>
                </div>
              ))}
            </motion.div>
          </AnimatePresence>
        </motion.div>
      </div>
    </>
  );
}

export default App;
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/c5ae5bfc-45f7-76f4-f06e-18636bc6c611.png)

以上でハンズオンは終了です！お疲れ様でした。

# 課題

ここまでの内容をより理解するために課題を用意したのでぜひチャレンジしてみてください。

チュートリアルで楽観的更新を行いましたが、キーワード検索をしてステータス更新をすると楽観的更新が行われません。

```ts
  updateOptimisticBooks(
    prevState.allBooks.map((book) =>
      book.id === id ? { ...book, status } : book
    )
  );
```

キーワード検索した状態でも楽観的更新ができるように実装を変更して下さい

# おわりに

今回はReact19の中でも特に利用しそうな機能を紹介しました！
これまでのuseStateやuseEffectなどを使えば同じ機能は実現できますが、よりスマートに書けるようにするためにぜひとも利用してみてください！

テキストでは細かく解説しましたが、一部説明しきれていない箇所もありますので動画教材で紹介しています。合わせてご利用ください。

<iframe width="560" height="315" src="https://www.youtube.com/embed/_TUgbgIirus?si=GBmYytakvQ22uGEC" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

# JISOUのメンバー募集中！
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか？
興味のある方は、ぜひホームページからお気軽にカウンセリングをお申し込みください！
▼▼▼

https://projisou.jp

# 図解ハンズオンたくさん投稿しています！

https://qiita.com/Sicut_study/items/c12faaf962646bee34f7

https://qiita.com/Sicut_study/items/7dc1b0cdcc1bee210f05

https://qiita.com/Sicut_study/items/13c9f51c1f9683225e2e

# 本記事のレビュアーの皆様
tokec様
吉田侑平様
ARISA様
上嶋晃太様
たけしよしき様

次回のハンズオンのレビュアーは[X](https://x.com/Sicut_study)にて募集します。

# 参考

https://ja.react.dev/blog/2024/12/05/react-19

https://zenn.dev/uhyo/books/react-19-new