EY-Office ブログ

RSC時代のミニマルReactフレームワークWakuを使ってワクワクしよう!

WakuというReactフレームワークを知っていますか?

React用ステート管理ライブラリーJotaizustandの開発者、@dai_shiさんが作られた、React server ComponentをサポートするミニマルなReactフレームワークです。

Waku https://waku.gg より

Wakuの特徴

Wakuは現在開発中でドキュメントはまだ完成していませんが、以下の記事からコンセプトや機能、使い方はわかります。しかも日本語記事があるのは嬉しいですね。

現在の0.19.4バージョンでは以下の機能があります。

  • React server Component(RSC)をサポート、Server Actionsも動作します
  • Viteをベースにしているので開発環境が快適
  • ルーティングをサポート、現在はcreatePage()API呼び出しによる記述ですが、Roadmapによるとファイルベースのルーティングも予定されているようです
    • とうぜん、レイアアウト機能もサポートされています
  • SSRもサポートされています、RSCの上でのSSRの実装だそうです
  • npm create waku@latestで直ぐにWakuを使う事が出来ます

create waku@latestで作られるプロジェクト

現在のcreate waku@latestで作られるプロジェクトは以下のようなディレクトリー構造になっています

  • ① Reactコンポーネントは src/components におきます
  • entries.tsxにはルーティングの定義が書かれています
  • ③ Viteベースなのでmain.tsxエントリーポイント
  • templates/はURLと対応するコンポーネントの置き場になっています
  • ⑤ 最初からTailwind CSSがインストールされています
  • ⑥ もちろんTypeScript対応です
├── node_modules
│   └── ...
├── package-lock.json
├── package.json
├── postcss.config.js                         # ← ⑤
├── public
│  └── ...
├── src
│   ├── components                            # ← ①
│   │   ├── counter.tsx
│   │   ├── error-boundary.tsx
│   │   ├── footer.tsx
│   │   └── header.tsx
│   ├── entries.tsx                           # ← ②
│   ├── main.tsx                              # ← ③
│   ├── styles.css
│   └── templates                             # ← ④
│       ├── about-page.tsx
│       ├── home-page.tsx
│       └── root-layout.tsx
├── tailwind.config.js                        # ← ⑤
└── tsconfig.json                             # ← ⑥

使ってみた

以前Next.jsで作ったRSCアプリをWakuで動かしてみました。
毎度お馴染みのジャンケンアプリです。😃 ジャンケンの結果はPrismaを使いRDB(sqlite3)に格納されます。

  • src/templates/home-page.tsx

このアプリのメイン・コンポーネントです、Next.jsで書いたコードとほぼ同じです。
大きな違いはTailwind CSSくらいです。

  • ① サーバーコンポーネント
  • ② pon関数はサーバーで実行されますが、クライアントコンポーネントから呼び出されるServer Actionsです
"use server";                                        // ← ①
import { PrismaClient } from '@prisma/client';
import { Te, Score, randomHand, judge } from "../lib/jyanken.js";
import JyankenBox from "../components/JyankenBox.js";
import Header from "../components/Header.js";
import ScoreList from "../components/ScoreList.js";
import Paper from "../components/Paper.js";

const prisma = new PrismaClient();

export async function pon(humanHand: Te) {           // ← ②
  "use server";

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


export const HomePage = async () => {
  const scores = await prisma.scores.findMany({orderBy: {id: 'desc'}});

  return (
    <div className="md:ml-8">
      <Header>じゃんけん ポン!</Header>
      <Paper className="md:w-3/5">
        <JyankenBox actionPon={pon} />
        <ScoreList scores={scores} />
      </Paper>
    </div>
  );
};
  • src/components/JyankenBox.tsx 初期コード

  • ① ジャンケンボタンがあるこのコンポーネントはクライアントで動きます

しかし、問題がおきました。ボタンを押すとServer Actionsは実行されRDBにデータは作成されますが HomePageコンポーネントは再描画されないので、ジャンケンの結果が画面に表示されません。🥺

"use client";                                  // ← ①
import Button from './Button.js';
import { Te } from "../lib/jyanken.js";

type JyankenBoxProps = {
  actionPon: (te: number) => void;
};
export default function JyankenBox({ actionPon }: JyankenBoxProps) {
  return (
    <div className="w-[230px] mx-auto mb-8 flex">
      <Button onClick={() => actionPon(Te.Guu)}>グー</Button>
      <Button className="mx-5" onClick={() => actionPon(Te.Choki)}>チョキ</Button>
      <Button onClick={() => actionPon(Te.Paa)}>パー</Button>
    </div>
  );
}
  • src/components/JyankenBox.tsx 解決版

Next.js版ではrevalidatePath()APIを使うと対応したサーバーコンポーネント再実行してくれました。

WakuのGitHubのIssuesで同じような機能はないのか質問したところ、ルーター機能のchangeLocation() APIでpath(URL)を変更しなくても遷移した事にして、同じページを再表示できると教えてもらえました。

また2回目以降はchangeLocation()が動作しない件も、Query (Search Paramameter) にタイムスタンプを付ける事で回避できるのではと教えてもらい。無事に動くようになりました。😇
さらにこのハックが無くても動作するように対応しているようです。

@dai_shiさん、ありがとうございます。

  • ① サーバーで動くジャンケンの処理を呼出し、終わるまで待つ
  • ② 暫定的にQueryにタイムスタンプを付けたchangeLocation() で再表示を誘導する
"use client";
import Button from './Button.js';
import { Te } from "../lib/jyanken.js";
import { useChangeLocation } from 'waku/router/client';

type JyankenBoxProps = {
  actionPon: (te: number) => void;
};
export default function JyankenBox({ actionPon }: JyankenBoxProps) {
  const changeLocation = useChangeLocation();

  const ponRefresh = async (te: Te) => {
    await actionPon(te);            // ← ①                // ↓ ②
    changeLocation(undefined, new URLSearchParams({ t: String(Date.now()) }));
  }
  return (
    <div className="w-[230px] mx-auto mb-8 flex">
      <Button onClick={() => ponRefresh(Te.Guu)}>グー</Button>
      <Button className="mx-5" onClick={() => ponRefresh(Te.Choki)}>チョキ</Button>
      <Button onClick={() => ponRefresh(Te.Paa)}>パー</Button>
    </div>
  );
}
  • src/components/ScoreList.tsx

ジャンケンの結果表示はサーバーで実行される普通のコンポーネントです。

import { Score } from "../lib/jyanken.js";
import Table from "./Table.js";

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

type ScoreListProps = {
  scores: Score[];
};
export default function ScoreList({ scores }: ScoreListProps) {
  return (
    <Table
      header={["時間", "人間", "コンピュータ", "結果"]}
      body={scores.map((score, ix) => (
        <ScoreListItem key={ix} score={score} />
      ))}
    />
  );
}

type ScoreListItemProps = {
  score: Score;
};
function ScoreListItem({ score }: ScoreListItemProps) {
  const teString = ["グー", "チョキ", "パー"];
  const judgmentString = ["引き分け", "勝ち", "負け"];
  const dateHHMMSS = (d: Date) => d.toTimeString().substring(0, 8);
  const tdClass = `px-3 md:px-6 py-4 ${JudgmentColor[score.judgment]}`;

  return (
    <tr className="bg-white border-b">
      <td className={tdClass}>{dateHHMMSS(score.matchDate)}</td>
      <td className={tdClass}>{teString[score.human]}</td>
      <td className={tdClass}>{teString[score.computer]}</td>
      <td className={tdClass}>{judgmentString[score.judgment]}</td>
    </tr>
  );
}
  • src/lib/jyanken.ts

ジャンケンのロジックなど。 Waku Githubのexamplesを参考にsrc/lib ディレクトリーに置きました。

export enum Te { Guu = 0, Choki, Paa }
export enum Judgment { Draw = 0, Win, Lose }
export type Score = {
  human: Te,
  computer: Te,
  judgment: Judgment,
  matchDate: Date
}
export type Status = {
  draw: number,
  win: number,
  lose: number
}

export const randomHand = (): Te => {
  return Math.floor(Math.random() * 3);
}

export const judge = (humanHand: Te, computerHand: Te): Judgment => {
  return (computerHand - humanHand + 3) % 3;
}

export const calcStatus = (scores: Score[]): Status => {
  const jugdeCount = (judge: Judgment) =>
    scores.reduce((count, score) => score.judgment === judge ? count + 1 : count, 0);

  return {
    draw: jugdeCount(Judgment.Draw),
    win:  jugdeCount(Judgment.Win),
    lose: jugdeCount(Judgment.Lose)
  };
}
  • その他のコード

その他コンポーネントやPrismaの設定

type HeaderProps = {
  children: React.ReactNode;
};
export default function Header({ children }: HeaderProps) {
  return <h1 className="my-4 text-3xl font-bold">{children}</h1>;
}

type PaperProps = {
  children: React.ReactNode;
  className?: string;
};
export default function Paper({ children, className }: PaperProps) {
  return <div className={`p-3 md:p-6 bg-white ${className}`}>{children}</div>;
}

type TableProps = {
  header?: string[];
  body: React.ReactElement<any, any>[];
};
export default function Table({ header, body }: TableProps) {
  return (
    <table className="w-full text-sm text-left text-gray-500">
      {header && (
        <thead className="bg-slate-50 border-r border-l border-b">
          <tr>
            {header.map((title, ix) => (
              <th key={ix} scope="col" className="px-3 md:px-6 py-3">
                {title}
              </th>
            ))}
          </tr>
        </thead>
      )}
      <tbody className="bg-white border-b border-r border-l">{body}</tbody>
    </table>
  );
}

type ButtonProps = {
  children: React.ReactNode;
  onClick: () => void;
  className?: string;
};
export default function Button({ children, onClick, className }: ButtonProps) {
  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 (
    <button type="button" onClick={onClick} className={`${buttonClass} ${className}`}>
      {children}
    </button>
  );
}


generator client {
  provider = "prisma-client-js"
}
datasource db {
  provider = "sqlite"
  url      = "file:./jyanken.db"
}

model Scores {
  id        Int      @id @default(autoincrement())
  human     Int
  computer  Int
  judgment  Int
  matchDate DateTime
}

まとめ

React Server Compnentは今後のWebアプリの開発を大きく変えるかもしれない技術です。現在はNext.jsで試すこができますが、Next.jsは過去の経緯などもあり、なんかモヤモヤするところがあります。

これに対しWakuはまだ発展途上ですが、応援したくなるフレームワークです。🤩

- about -

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