はじめに
ついにReact19が安定版になりました!!!!
React19になったことで「サーバーコンポーネントの正式対応」や「アクションの追加」など大きな変更が入りました。
この変更によってShadcnなど多くのライブラリが対応を頑張っている状況です(おそらく裏では...)
今回はそんなReact19の中でも特に知っておきたい機能を中心に紹介していきます。
ちなみにReact19の機能は実験的に少し前から公開されており、世の中にはすでに多くの記事やYoutube動画があります。
しかしそれらの記事や動画を見て思いました…
自分の能力が低いせいか理解はできるけど、実際に開発の中で使えるイメージがわかない!!
(つまり理解した気になっているだけ)
いままでにもReactを追ってはいますが、やはり過去を振り返ってもその場で理解はしているけど使えている実感はありません。
なぜその場では理解できているけど、実際に使うまで身につかないのか…
理由は実際に開発の中で使ったことがないから。
ということで今回のチュートリアルは以下の構成で行っています。
- React19の新機能をざっくり知る
- 具体的な例でそれぞれの機能を掘り下げる
- 書籍管理アプリを通して実際に利用して身につける
ハンズオンを通して実際に利用することで深く理解して使いこなせるようになります。
このようなハンズオンが調べた限りは全然ありませんでしたので、ぜひ活用して最新のReactを一気にキャッチアップしていただけると嬉しいです。
今回はこのようなアプリを作ります👇
動画教材も用意しています
こちらの教材にはより詳しく解説した動画もセットでご用意しています。
テキスト教材ではわからない細かい箇所があれば動画も活用ください。
対象者
- 最新のReactをキャッチアップしたい
- Reactをやってみたい初心者
- HonoでAPIを作成したい
- 更新された機能を使いこなせるようになりたい
- JavaScriptを少し理解している
- アプリを作って体系的に学びたい
React19の機能を知る
まず初めに今回React19で追加されたアクションを中心に特に使えるようになっておきたい機能について解説します。
ここではあくまで機能を理解するだけになります。
実際に新機能を使いこなせるようにするためにハンズオンも行うようにして下さい。
React19に関してはこちらでも理解できますが、より初心者向けに噛み砕いて解説をしていきます。
詳しく解説するのは青くなっている機能です。
今回の変更でアクションが多く追加されました。
アクションとは非同期処理(API叩くなど)をするような機能だと思ってください
このアクションを理解する上でSuspenseも外せないので先に説明します。
まずはこれがSuspenseがないコードです。
useEffect
(画面が表示される前に実行される処理)の中でfetchUserData
(ユーザーに関する情報を取得する処理)があります。
この処理が実行されている間は画面が表示されなくなってしまうので、isLoading
というステートをtrueにしてローディング画面をデータ取得中に表示するようにしています。
if (isLoading) return <LoadingSpinner />;
このように取得中をステート管理したり、ローディング画面かを条件分岐したりと大変です。
ここで活躍するのがSuspenseという機能です。
Suspenseタグで囲んだ中で非同期処理(Promise)を実行するとPromiseが解決するまでサスペンド状態となります。
そのサスペンドをが検知してローディング画面を表示してくれます。
非同期処理が終わるとサスペンドは解除されてユーザー詳細画面が表示されます。
これまでのReactはで取得系(GET)などの非同期処理はうまく扱えたのですが、更新系(PUTやPATCH)などはうまく取り扱えませんでした。
今回追加されたアクションで更新系の非同期処理もうまく扱えるようになりました。
1. use
useの引数にPromiseを渡すことで解決してくれるようになりました
これまでuseEffectで非同期処理を解決してところをuse
のみで解決できます。
2. useTransition
状態更新の優先度を下げてくれるフックです。
こうすることで状態更新の前に重い処理がある場合にユーザー操作がブロックされてしまうのを防ぐことができます。
useTransitionだけで使うことはあまりなく、この後紹介するアクションの土台となっている機能です。
3. useActionState
非同期処理をしたあとの結果でステート更新ができる機能です。
useTransition + useReducerのような機能で、非同期処理を待っている間はisPending
を利用してローディング画面などをだすことも可能です。
そのあと処理が終わったらステートが更新されるので、画面の更新(再レンダリング)が行われます。
4. formがアクションに対応
formタグがアクションに対応しました。
action
属性にアクションを指定することができるようになり、Submitイベントが発火すると実行されるようになります。
またアクションで実行されたときにはformData.get
を利用することでフォームの中身をとりだすこともできます。これによってreact-hook-formなどを利用しなくても簡単に扱えます。
5. useOptimistic
楽観的更新を実装するためのフックです。
楽観的更新とは、ユーザーのアクション結果を即座に画面に反映して、実際の処理(この例ではいいねをつける処理)は裏側で行う更新方法です。
楽観的更新をすることによって、ユーザーにはすぐに操作が反映されたように見えるのでユーザビリティが大きく向上します。
ここまでで書籍管理アプリに利用する新機能をざっくり解説したので、実際に使ってより深く理解していきましょう!
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を開いてサーバーが起動できたことを確認します。
続いてReactのバージョンを確認します。VSCodeでディレクトリを開いてpackage.json
をみます
Reactのバージョンは18系となっています。
今回の新機能は19になって安定版となったためバージョンを19に変えていきます。
$ npm i react@19 react-dom@19 @types/react@latest @types/react-dom@latest
これでReact19で開発する環境が整いました。
次にデザインのためにTailwindCSSをインストールしておきます。
$ npm install -D tailwindcss@3.4.13 postcss autoprefixer
$ npx tailwindcss init -p
プロジェクトをVSCodeで開いてtailwind.config.js
を以下に変えます
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
src/index.css
を変更します
@tailwind base;
@tailwind components;
@tailwind utilities;
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
で起動したらボタンが表示されました
それではここから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を追加していきます。
今回作成するAPIエンドポイントは以下の3つです。
- すべての書籍を取得する (GET /books)
- キーワード検索してヒットした書籍を取得する (GET /books)
- 書籍を新規追加する (POST /books)
- 書籍のステータス(在庫あり・貸出中・返却済)を更新する (PUT /books/:id)
すべての初期取得とキーワード検索は同じエンドポイントの中で行うことにします。
2-1. 書籍の取得
まずは書籍を取得するAPIから作成します。
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
をタイトルに含む書籍を返すようにします。
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
を作成します。
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
です。
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
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
クラスとしました。
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を用意してあげています。
それでは実行しましょう
frontend
とbackend
のディレクトリでそれぞれnpm run devをしてサーバーを起動します。
画面をみると
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はアクセスを拒否するという仕組みがあると思ってください。
ちなみにオリジンとは「プロトコル」+「ホスト名」+「ポート番号」のことを言います。
https://example.com:5173
↑ ↑ ↑
プロトコル ホスト名 ポート番号
それではAPIにhttp://localhost:5173
を許可する設定を入れましょう
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,
});
画面を開いてみるとテストデータが表示されるようになりました
試しにSuspense
も利用してみましょう
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>
);
use
を使って取得している間はサスペンド状態となりfallback
が代わりに表示されるようになりました!
今回はこのあとのハンズオンの関係で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
を利用して書籍追加を実装します。
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
で非同期処理をサスペンドします。
isPending
がtrue
なら非同期処理中ということがわかります。
ボタンの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ステートに新しい書籍を追加しています。
ここで新しいフックであるuseActionState
とform
を使ってよりスマートに書いてみます。
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[];
};
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>;
})}
画面を確認すると同じ挙動で実装することができました!
App.tsxにコードが増えたので最後にリファクタリングをします。
$ touch src/bookActions.ts // frontendフォルダ
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],
};
};
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. 書籍を検索できるようにする
同じ要領で検索を実装します。
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も許容しています
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にも新しい書籍を追加するようにしています。
次に画面に検索フォームを追加します。
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入門」のみがヒットします。
検索ができるようになりました!
6. 書籍のステータスを更新しよう!
書籍にはステータス『在庫あり』『貸出中』『返却済』を付与することができます。
追加や検索と同じ考え方で実装していきましょう
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
);
画面もこれまでは書籍名のみでしたがステータスを表示するようにしましょう
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;
actionHandlers
にupdate
を追加しました。
ハンドラーがあるかないかの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
も更新されます。
ステータス更新ができるようになりました!
7. 楽観的更新でユーザー体験を向上しよう
最後にuseOptimistic
を使って楽観的更新をします。
いいね機能などは楽観的更新を利用することが多いです。いいねを押すとすぐに色がついていいねを押したことがわかりますが、実際の処理は押した直後に行われるのではなくUIを更新したあとに裏側で行われています。
こうすることでユーザーに即時フィードバックができユーザー体験の向上につながります。
今回は書籍ステータスに楽観的更新を利用してみましょう
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;
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
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;
以上でハンズオンは終了です!お疲れ様でした。
課題
ここまでの内容をより理解するために課題を用意したのでぜひチャレンジしてみてください。
チュートリアルで楽観的更新を行いましたが、キーワード検索をしてステータス更新をすると楽観的更新が行われません。
updateOptimisticBooks(
prevState.allBooks.map((book) =>
book.id === id ? { ...book, status } : book
)
);
キーワード検索した状態でも楽観的更新ができるように実装を変更して下さい
おわりに
今回はReact19の中でも特に利用しそうな機能を紹介しました!
これまでのuseStateやuseEffectなどを使えば同じ機能は実現できますが、よりスマートに書けるようにするためにぜひとも利用してみてください!
テキストでは細かく解説しましたが、一部説明しきれていない箇所もありますので動画教材で紹介しています。合わせてご利用ください。
図解ハンズオンたくさん投稿しています!
本記事のレビュアーの皆様
tokec様
吉田侑平様
ARISA様
上嶋晃太様
たけしよしき様
次回のハンズオンのレビュアーはXにて募集します。
参考