EY-Office ブログ

React19のActions, useActionState Hookを使ってみた

今回もReact 19 Betaの新機能を解説です。 2回目はActionsです。

Actionsの冒頭は

Reactアプリでよくあるユースケースは、データの変異を実行し、そのレスポンスでステートを更新することだ。例えば、ユーザーが名前を変更するためにフォームを送信すると、APIリクエストを行い、そのレスポンスを処理する。以前は、保留状態、エラー、楽観的な更新、連続したリクエストを手動で処理する必要があった。

ではじまり、Actionsの一般的なケースを処理する新しいuseActionState Hookなどが導入しされています。

react19actions 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なので、バックエンドと通信するスタイルでも使えると思います。

これから、Actionsは、Susupense 同様にReactの宣言的なコードとして定着して行くのでしょうか。

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ