WakuというReactフレームワークを知っていますか?
React用ステート管理ライブラリーJotaiやzustandの開発者、@dai_shiさんが作られた、React server ComponentをサポートするミニマルなReactフレームワークです。
Wakuの特徴
Wakuは現在開発中でドキュメントはまだ完成していませんが、以下の記事からコンセプトや機能、使い方はわかります。しかも日本語記事があるのは嬉しいですね。
- Introduction
- ワクワクするReactフレームワーク「Waku」を開発中です
- React Server Componentsの実装を最小限で実現するWaku v0.17.0をリリースしました
- 正式リリースまでのロードマップがv1 Roadmap #24に示されています
現在の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はまだ発展途上ですが、応援したくなるフレームワークです。🤩