今回もReact 19 Betaの新機能を解説です。 2回目はActionsです。
Actionsの冒頭は
Reactアプリでよくあるユースケースは、データの変異を実行し、そのレスポンスでステートを更新することだ。例えば、ユーザーが名前を変更するためにフォームを送信すると、APIリクエストを行い、そのレスポンスを処理する。以前は、保留状態、エラー、楽観的な更新、連続したリクエストを手動で処理する必要があった。
ではじまり、Actionsの一般的なケースを処理する新しいuseActionState Hookなどが導入しされています。
Bing Image Creatorが生成した画像を使っています
useActionState Hookとは
先週のブログの中で Susupenseが非同期通信でデータを取得し表示するコンポーネントの作成を定型的に書けるようにしたのと同様に、
useActionState Hookはフォームの
- フォームに入力された値の取得や管理
- バックエンドのアクション(処理)の実行
- バックエンドからの戻り値で、コンポーネントが管理するStateの更新
- 通信中Stateの管理
等を定型的に書けるHookです。
やはり環境作りで悩む
今期は、React Server Components (RSC)を使いたかったので Next.jsを使う事にしました。create-next-appを使って作ったNext.js環境にReact 19 Betaをインストールしてみましたが、上手くuseActionState
は動きませんでした。
いろいろと調べたところ、create-next-app使って作ったNext.js環境にnextのcanary版をインストールすれば良いようです。
(この環境ではReact19 Betaはインストールされず、React18 Canaryが動作しているようですが、useActionState
は使えるので良しとします)
コード
コードは、以前書いたNext.jsのReact Server Componentsを試してみたを元にしています。ただし<form>
を使いたかったので下画像のように、ジャンケンの手をラジオボタンにしたり、コメントの入力を追加しました。
page.tsx
トップレベルのコンポーネントは、サーバーコンポーネントでデータベースから対戦結果を取得し、メインのジャンケン・コンポーネント (Jyanken
) を呼び出しています。
getScores()
はJyankenAction.tsで説明します
"use server";
import Jyanken from './_components/Jyanken';
import { getScores } from './_libs/JyankenAction';
export default async function Home () {
const scores = await getScores();
return (
<Jyanken defaultScores={scores} />
);
}
Jyanken.tsx
ジャンケンのメインコンポーネントです。
- scoresは対戦結果のState
- ①
useActionState
の戻り値は- scoresは対戦結果の更新されたState
- formActionは
<form>
に渡されるアクション - isPendingはバックエンド通信中(処理中)は
true
- ②
useActionState
の呼出し- テンプレートにはStateの型、アクションの第2引数の型
ponAction
はJyankenAction.tsで説明するフォームのアクション- defaultScoresはStateの初期値、page.tsxから渡ってきます
- ③ ジャンケンボタンのコンポーネント
JyankenBox
にはアクションを渡しています - ④ isPendingが
true
の間は通信中(処理中)なので⌛ Downloading scores...
を表示 - ⑤ isPendingが
false
なら、対戦結果表示コンポーネントScoreBox
を表示
"use client";
import ScoreBox from '../_components/ScoreBox';
import JyankenBox from '../_components/JyankenBox';
import { useActionState } from 'react';
import { ScoreType } from '../_libs/JyankenType';
import { ponAction } from '../_libs/JyankenAction';
type JyankenProps = {
defaultScores: ScoreType[]
}
export default function Jyanken ({defaultScores}: JyankenProps) {
const [scores, formAction, isPending] = // ← ①
useActionState<ScoreType[], FormData>(ponAction, defaultScores); // ← ②
return (
<>
<h1>じゃんけん ポン!</h1>
<JyankenBox formAction={formAction} /> {/* ← ③ */}
{isPending ? (<p>⌛ Downloading scores...</p>) : // ← ④
<ScoreBox scores={scores} /> // ← ⑤
}
</>
);
}
JyankenBox.tsx
ジャンケンボタンのコンポーネント。
<form>
、<input>
から作られた通常のフォーム- 入力値をStateで管理したり、
useRef
で値を取り出したりする必要はありません! - ① 引数で受け取ったアクションを
<form>
のaction=
に指定
import { Te } from '../_libs/JyankenType';
type JyankenBoxProps = {
formAction: (formData: FormData) => void
}
export default function JyankenBox ({formAction}: JyankenBoxProps) {
const divStyle = {marginLeft: 8};
const buttonStyle = {marginLeft: 20, padding: "0 15px"};
const radioStyle = {marginLeft: 10};
const inputStyle = {display: 'block', marginLeft: 10, marginTop: 5};
return (
<>
<div style={divStyle}>
<form action={formAction}> {/* ← ① */}
<input type="radio" name="te" value={Te.Guu} style={radioStyle} /> グー
<input type="radio" name="te" value={Te.Choki} style={radioStyle} /> チョキ
<input type="radio" name="te" value={Te.Paa} style={radioStyle} /> パー
<button type="submit" style={buttonStyle}>ポン</button>
<label style={inputStyle}>
コメント: <input type="text" name="comment" />
</label>
</form>
</div>
</>
);
}
ScoreBox.tsx
対戦結果表示コンポーネント、とくに解説することはありません。
import { ScoreType } from "../_libs/JyankenType";
type ScoreBoxProps = {
scores: ScoreType[];
}
export default function ScoreBox ({scores}: ScoreBoxProps ) {
const teString = ["グー", "チョキ", "パー"];
const judgmentString = ["引き分け", "勝ち", "負け"];
const tableStyle = {marginTop: 20, borderCollapse: "collapse"};
const thStyle = {border: "solid 1px #888", padding: "3px 15px"};
const tdStyle = {border: "solid 1px #888", padding: "3px 15px", textAlign: "center"};
return (
<table style={tableStyle}>
<thead>
<tr>
<th style={thStyle}>あなた</th>
<th style={thStyle}>コンピュター</th>
<th style={thStyle}>勝敗</th>
<th style={thStyle}>コメント</th>
</tr>
</thead>
<tbody>
{scores.map((scrore, ix) =>
<tr key={ix}>
<td style={tdStyle}>{teString[scrore.human]}</td>
<td style={tdStyle}>{teString[scrore.computer]}</td>
<td style={tdStyle}>{judgmentString[scrore.judgment]}</td>
<td style={tdStyle}>{scrore.comment}</td>
</tr>
)}
</tbody>
</table>
);
}
JyankenAction.ts
サーバーで実行されるアクション(処理)、Prisma + sqlite3でデータベースを操作しています。
- ① 対戦結果を保存するscoresテーブルから全データを取得する
getScores
関数- 最新の結果から並ぶように
order by
指定
- 最新の結果から並ぶように
- ②
<form>
がsubmitされた際に実行されるアクションponAction
関数- prevStateは現在のState値
- formDataは
<form>
データのオブジェクト → 仕様
- ③ 選択されているラジオボタンの値(
name="te"
)を取得- teに値が無ければラジオボタンは未選択なので、現状Stateを戻し終了
- ④ ジャンケンの実行
- human: 人間の手
- computer: 乱数で発生したコンピューターの手
- judgment: 勝敗
- ⑤ comment: コメント入力値の取得
- ⑥ scoresテーブルにscoreを書き込む
- ⑦ isPendingが判るように、少し待つ
- ⑧ 現状Stateの先頭にscoreを追加した配列を新Stateとして戻す
"use server";
import { PrismaClient } from '@prisma/client';
import { ScoreType, Te, Judgment } from './JyankenType';
const prisma = new PrismaClient();
const sleep = (sec: number) => new Promise(resolve => setTimeout(resolve,
sec * 1000));
export const getScores = async (): Promise<ScoreType[]> => { // ← ①
const scores = await prisma.scores.findMany({orderBy: {id: 'desc'}});
return scores as ScoreType[];
}
export const ponAction = async (prevState: ScoreType[], formData: FormData):
Promise<ScoreType[]> => { // ← ②
const te = formData.get("te"); // ← ③
if (!te) {
return prevState;
}
const human = Number(te) as Te; // ← ④
const computer = Math.floor(Math.random() * 3) as Te;
const judgment = (computer - human + 3) % 3 as Judgment;
const comment = formData.get("comment") as string; // ← ⑤
const score: ScoreType = {human, computer, judgment, comment};
await prisma.scores.create({ data: score }); // ← ⑥
await sleep(0.5); // ← ⑦
return [score, ...prevState]; // ← ⑧
}
JyankenType.ts
ジャンケン処理で使う型、TeやJudgmentはEnumを止めUnionを使う事にしました。
export const Te = {Guu: 0, Choki: 1, Paa: 2} as const;
export type Te = (typeof Te)[keyof typeof Te];
const Judgment = {Draw: 0, Win: 1, Lose: 2} as const;
export type Judgment = (typeof Judgment)[keyof typeof Judgment];
export type ScoreType = {
human: Te;
computer: Te;
judgment: Judgment;
comment: string | null;
};
まとめ
繰り返しになりますが、useActionState はフォームの処理、
- フォームに入力された値の取得や管理
- バックエンドのアクション(処理)の実行
- バックエンドからの戻り値で、コンポーネントが管理するStateの更新
- 通信中Stateの管理
を定型的に書けるHookです。
今回はReact Server Componentsを使いましたが、useActionStateはクライアントで動くHookなので、バックエンドと通信するスタイルでも使えると思います。