142
132

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-02-02

React19 (1).png

はじめに

image.png

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

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

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

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

image.png

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

自分の能力が低いせいか理解はできるけど、実際に開発の中で使えるイメージがわかない!!

(つまり理解した気になっているだけ)

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

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

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

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

image.png

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

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

対象者

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

React19の機能を知る

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

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

React19に関してはこちらでも理解できますが、より初心者向けに噛み砕いて解説をしていきます。

image.png

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

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

image.png

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

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

if (isLoading) return <LoadingSpinner />;

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

image.png

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

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

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

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

1. use

image.png

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

2. useTransition

image.png

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

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

3. useActionState

React19.png

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

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

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

image.png

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

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

5. useOptimistic

image.png

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

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

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

1. React19の環境構築

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

今回は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を開いてサーバーが起動できたことを確認します。
 

image.png

続いてReactのバージョンを確認します。VSCodeでディレクトリを開いてpackage.jsonをみます
 
image.png

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

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

image.png

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

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

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

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

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

src/index.cssを変更します

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

frontend/src/App.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

それではここから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

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

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

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

2-1. 書籍の取得

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

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,
});

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

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)はこのように作りました。
現在はテストデータをすべて返すようになっています。

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

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

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をタイトルに含む書籍を返すようにします。

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を使って行っています。

  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を作成します。

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,
});

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

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

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

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

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

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

  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です。

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を取得して、ボディから変更するステータスを受け取ります。

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

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

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

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

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

  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
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クラスとしました。

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を使わないで書籍一覧を取得することが可能です。

const initialBooks = use(fetchManageBookPromise);
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を間に挟んでいることです。

const fetchManageBookPromise = fetchManageBook();

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

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

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

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

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

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

image.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://example.com:5173
↑      ↑            ↑
プロトコル  ホスト名      ポート番号

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

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

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

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

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

今回はこのあとのハンズオンの関係でmain.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. 新しい書籍を追加しよう

まずはuseTransitionuseStateを利用して書籍追加を実装します。

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つのステートを用意しました

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

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

  const [isPending, startTransition] = useTransition();

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

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

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

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

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

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

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

  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ステートに新しい書籍を追加しています。
ここで新しいフックであるuseActionStateformを使ってよりスマートに書いてみます。

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[];
};
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を直接操作ができインプットフォームをクリアしても再レンダリング(画面の更新)が走りません。

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

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

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

    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,
    }

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

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

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

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

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

      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でインプットフォームの内容を取れるようになったので入力した値を取り出しています。なければ追加ができないのでエラーを返しました。

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

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

      addFormRef.current?.reset();

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

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

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

    {
      allBooks: initialBooks,
    }

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

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

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

image.png

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

$ touch src/bookActions.ts // frontendフォルダ
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],
  };
};
/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も許容しています

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はキーワードが必須なのでキーワードがない場合はエラーを出します

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

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

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

  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,
  };

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

  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を実行します。

        <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")とすることで判断することができます。

  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を呼び出しています。
もしアクションがない場合はエラーにして返します。

      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で取得したすべての書籍で検索するとヒットした書籍を表示できます。

  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

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

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

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,
  };
};

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

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を叩いたあとにステートの該当する書籍のステータスを直接変更しています。

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

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

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;

      // しゅうs
      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;

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

      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}`);
      }

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

            {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をふることです。

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

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

image.png

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

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

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

image.png

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

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;
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秒程度のスリープを入れると違いがわかりやすいです。

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
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

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

課題

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

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

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

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

おわりに

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

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

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

本記事のレビュアーの皆様

tokec様
吉田侑平様
ARISA様
上嶋晃太様
たけしよしき様

次回のハンズオンのレビュアーはXにて募集します。

参考

142
132
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
142
132

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?