EY-Office ブログ

ViteベースのRSCに対応したフレームワーク@lazarv/react-serverにふれてみたよ(2)

先週のブログの続きです、先週は@lazarv/react-serverというViteベースのRSCに対応したReactフレームワークを使い、やっとの思いでサーバー・コンポーネントのみを使ったジャンケンアプリを動かしました。
今回はサーバー・コンポーネントとクライアント・コンポーネントを組み合わせた、いつものジャンケンアプリを作る事が出来ました。

@lazarv/react-server @lazarv/react-serverホームページより

クライアント・コンポーネントを使う

現在の@lazarv/react-serverでは間違って使い方をすると、以下の画像のような意味不明のエラーが出てきて悩まされます。

先週はクライアント・コンポーネントからServer Functionを呼び出せなかったですが、やっと使い方が判りました。 公式ドキュメントのServer functions with client componentsに書かれているようにクライアント・コンポーネントでServer functionを使うには、サーバー・コンポーネントからクライアント・コンポーネントのPropsにServer functionを渡してあげる必要があります。

クライアント・コンポーネント内からは直接的にServer functionを呼び出せないようです。成功したコードは以下のようになりました。

ジャンケンボタンのクライアント・コンポーネント

JyankenBoxコンポーネントは、ボタンを押したさいに実行される関数はPropsで受け取っています。

"use client";

import { Te } from "./jyanken";

type JyankenBoxProps = {
  pon: (humanHand: Te) => void
}
export default function JyankenBox ({pon}: JyankenBoxProps) {

  const buttonClass = `text-white text-center text-sm rounded w-16 px-2 py-2
    bg-blue-600 hover:bg-blue-700 shadow shadow-gray-800/50`;

  return (
    <div className="w-[270px] mx-auto flex justify-center mb-10">
      <button type="button" onClick={() => pon(Te.Guu)} className={buttonClass}>
        グー
      </button>
      <button type="button" onClick={() => pon(Te.Choki)} className={`${buttonClass} mx-5`}>
        チョキ
      </button>
      <button type="button" onClick={() => pon(Te.Paa)} className={buttonClass}>
        パー
      </button>
    </div>
  );
}
JyankenBoxを呼出すサーバー・コンポーネント

JyankenBoxコンポーネントのPropsに、Server functionのpostJyankenを渡しています。

import Layout from "./Layout";
import JyankenBox from "./JyankenBox";
import ScoreList from "./ScoreList";
import { getScores, postJyanken } from "./jyankenFunctions";

export default async function App() {

  const scores = await getScores();

  return (
    <Layout>
      <div className="mx-8 w-1/2">
        <h1 className="my-6 text-center text-xl font-bold">
          対戦結果
        </h1>
        <JyankenBox pon={postJyanken} />
        <ScoreList scores={scores} />
      </div>
    </Layout>
  )
}
Server functionのコード

postJyanken関数の中でジャンケンを行い結果をRDBに格納しています。最後のreload()でジャンケン結果表示画面を再表示しています

"use server";

import { PrismaClient } from "@prisma/client";
import { judge, randomHand, Score, Te } from "./jyanken";
import { reload } from "@lazarv/react-server";

const prisma = new PrismaClient();

export async function getScores() {
  const scores = await prisma.scores.findMany({orderBy: {id: 'desc'}, take: 10});
  return scores as Score[];
}

export async function postJyanken(humanHand: Te) {
  const computerHand = randomHand();
  const score: Score = {
    human: humanHand,
    computer: computerHand,
    judgment: judge(humanHand, computerHand),
    matchDate: new Date()
  };
  await prisma.scores.create({ data: score });
  reload();
}

ルーティング

@lazarv/react-serverにはルーティング機能があります。

今回はFile-system based routerを使い、いつものジャンケンの対戦結果と対戦成績を表示できるアプリを作ってみました。

ディレクトリー・ファイル構造は、以下のようにNext.jsと同様になっています。

└── src
    ├── app
    │   ├── global.css
    │   ├── layout.tsx
    │   ├── page.tsx
    │   ├── scores
    │   │   └── page.tsx
    │   └── status
    │       └── page.tsx
    ├── compnents
    │   ├── ApplicationBar.tsx
    │   ├── JyankenBox.tsx
    │   ├── ScoreList.tsx
    │   └── StatusBox.tsx
    └── libs
        ├── jyanken.ts
        └── jyankenFunctions.ts

コード

src/app/scores/page.tsx

/scoresに対応し、対戦結果を表示します。File-system based routerのLayout機能を使っています。

import JyankenBox from "@/compnents/JyankenBox";
import ScoreList from "@/compnents/ScoreList";
import { getScores, postJyanken } from "@/libs/jyankenFunctions";

export default async function App() {

  const scores = await getScores();

  return (
    <div className="mx-8 w-1/2">
      <h1 className="my-6 text-center text-xl font-bold">
        対戦結果
      </h1>
      <JyankenBox pon={postJyanken} />
      <ScoreList scores={scores} />
    </div>
  )
}
src/app/status/page.tsx

/statusに対応し、対戦成績を表示します。

import JyankenBox from "@/compnents/JyankenBox";
import StatusBox from "@/compnents/StatusBox";
import { calcStatus } from "@/libs/jyanken";
import { getScores, postJyanken } from "@/libs/jyankenFunctions";

export default async function App() {
  const scores = await getScores();

  return (
    <div className="mx-8 w-1/2">
      <h1 className="my-6 text-center text-xl font-bold">
        対戦結果
      </h1>
      <JyankenBox pon={postJyanken} />
      <StatusBox status={calcStatus(scores)} />
    </div>
  )
}
src/app/page.tsx

/にアクセスした場合は、/scores にリダイレクトします。

import { redirect } from "@lazarv/react-server";

export default async function App() {
  redirect("/scores");
}
src/app/layout.tsx

File-system based routerのLayout定義。

import ApplicationBar from "@/compnents/ApplicationBar";
import "./global.css";

export default function Layout({ children }: React.PropsWithChildren) {
  return (
    <html lang="ja">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>ジャンケン</title>
      </head>
      <body>
        <ApplicationBar />
        {children}
      </body>
    </html>
  );
src/compnents/StatusBox.tsx

対戦成績の表示コンポーネント。

import { Judgment, Status } from "@/libs/jyanken";

const JudgmentColor = ["text-[#000]", "text-[#2979ff]", "text-[#ff1744]"];

type StatusBoxProps = {
  status: Status;
};
export default function StatusBox({ status }: StatusBoxProps){
  return (
    <table className="w-full text-sm text-left text-gray-500">
      <tbody className="bg-white border">
        <StatusItem title="勝ち" judge={Judgment.Win} count={status.win} />
        <StatusItem title="負け" judge={Judgment.Lose} count={status.lose} />
        <StatusItem title="引き分け" judge={Judgment.Draw} count={status.draw} />
      </tbody>
    </table>
  );
};

type StatusItemProps = {
  title: string;
  judge: Judgment;
  count: number
}
function StatusItem({title, judge, count}: StatusItemProps) {
  return (
    <tr key={judge} className="bg-white border-b">
      <th scope="row" className="pl-16 py-4">{title}</th>
      <td className={`text-right pr-16 py-4 ${JudgmentColor[judge]}`}>{count}</td>
    </tr>
  )
}
src/compnents/ApplicationBar.tsx

アプリケーションバーの表示コンポーネントです。クライアント・コンポーネントで、Client-side navigation を使い対戦結果/対戦成績のリンクを実現しています。

"use client";

import { useClient } from "@lazarv/react-server/client";

export default function ApplicationBar() {
  const { navigate } = useClient();

  const linkCSS = "py-2 px-3 text-blue-100 rounded hover:bg-blue-700";
  return (
    <nav className="bg-blue-600 border-gray-50">
      <div className="max-w-screen-xl flex flex-wrap items-center mx-auto p-3">
        <h1 className="ml-5 text-2xl font-bold text-white">じゃんけん ポン!</h1>
          <ul className="font-medium flex p-2 bg-blue-600">
            <li>
              <button onClick={() => navigate("/scores")} className={linkCSS}>対戦結果</button>
            </li>
            <li>
              <button onClick={() => navigate("/status")} className={linkCSS}>対戦成績</button>
            </li>
          </ul>
      </div>
    </nav>
  );
}

その他のファイルは今回のブログや先週のブログと同じです。
さらに以下の設定ファイルを追加しています

react-server.config.json

File-system based routerの定義をしています。

{
  "root": "src/app",
  "page": {
    "include": ["**/page.tsx"]
  }
}
vite.config.mjs

ソースコードのimportで @/ を使うための定義です。

import { defineConfig } from "vite";

export default defineConfig({
  resolve: {
    alias: {
      "@/": new URL("src/", import.meta.url).pathname,
    },
  },
});

まとめ

現在の@lazarv/react-serverは、まだエラーメッセージの的確化やドキュメントの充実が必要ですが、Viteベースで React Server Component や Server Function が使えるフレームワークが増えるのは素晴らしいですね。

現状ではNext.jsやWakuに比べると制限が多いですが、色々なフレームワークが出てくるのは良い事だと思います。

- about -

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