TanStack Query (旧: React Query)やTanStack Router 、TanStack Table・・・など高品質のフロントエンドライブラリーを提供しているTanStackからフルスタックフレームワーク TanStack Startがリリースされました❗
現在はAlpha版ですが、既にしっかりとしたGetting Startページがあるので試してみました。
TanStackホームページより
TanStack Startとは
上の画像はTanStack Startのホームページに少し手を加えたものですが、そこには
- ENTERPRISE-GRADE ROUTING
- SSR, STREAMING AND SERVER RPCS
- CLIENT-SIDE FIRST, 100% SERVER CAPABLE
- DEPLOY ANYWHERE WITH VINXI & VITE
とコンセプトが書かれています、またTanStack Start OverviewにTanStack Startは強力なTanStack Routerの上に構築されたフルスタックフレームワークで以下の機能をもっています。
- Full-document SSR
- Streaming
- Server Functions / RPCs
- Bundling
- Deployment
- Full-Stack Type Safety
Getting Started
Getting Startedに書かれた手順でReactプロジェクトを作ると、下の画像のようなカウンターのアプリが問題無く出来ました! Alpha版なのに立派です。😁
詳細は省略しますが、このプロジェクトにはたくさんの設定ファイルやコードファイルがあります。
├── app
│ ├── client.tsx
│ ├── routeTree.gen.ts
│ ├── router.tsx
│ ├── routes
│ │ ├── __root.tsx
│ │ └── index.tsx
│ └── ssr.tsx
├── app.config.ts
├── count.txt
├── package-lock.json
├── package.json
└── tsconfig.json
ただし、この中でアプリケーションのコードは app/routes/index.tsx
のみです。
簡単に解説すると、
- ① count.txtファイルを読み込み数値にして戻します、もしファイルが存在せずエラーのになった場合は 0 を戻します
- ② ①の関数をServer Function(サーバーがで実行する関数)として登録しています
- ③ これもServer Functionで、count.txtファイル内容の数値に引数
addby
を足した値を、count.txtファイルの書き込みます - ④ TanStack Routerのルーティング作成関数で
'/'
パスをHomeコンポーネントに割付'/'
がアクセスされた時にはloader:
に定義された、getCount()
が呼び出されるようにしています
- ⑤ カウンターの値(
getCount()
の結果)をstate変数に代入しています - ⑥ ボタンが押されると
updateCount()
が実行され- その後、
invalidate()
で④で定義されたloader:
の再実行、再描画が起きます
import * as fs from 'node:fs'
import { createFileRoute, useRouter } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/start'
const filePath = 'count.txt'
async function readCount() { // ← ①
return parseInt(
await fs.promises.readFile(filePath, 'utf-8').catch(() => '0'),
)
}
const getCount = createServerFn('GET', () => { // ← ②
return readCount()
})
// ↓ ③
const updateCount = createServerFn('POST', async (addBy: number) => {
const count = await readCount()
await fs.promises.writeFile(filePath, `${count + addBy}`)
})
export const Route = createFileRoute('/')({ // ← ④
component: Home,
loader: async () => await getCount(),
})
function Home() {
const router = useRouter()
const state = Route.useLoaderData() // ← ⑤
return (
<button
type="button"
onClick={() => {
updateCount(1).then(() => {
router.invalidate() // ← ⑥
})
}}
>
Add 1 to {state}?
</button>
)
}
いつものジャンケン・アプリを作ってみよう
さていつものジャンケン・アプリを作ってみます。たぶんPrismaを使っても良かったのですが、今回はGetting Startedと同様にファイルに対戦結果を書く事にしました。
ディレクトリー構造は、TanStack Start(Router)のExampleを参考にしました。■が付いているのが今回作成したファイルです。
├── app
│ ├── client.tsx
│ ├── components
│ │ ├── JyankenBox.tsx ■
│ │ └── ScoreBox.tsx ■
│ ├── routeTree.gen.ts
│ ├── router.tsx
│ ├── routes
│ │ ├── __root.tsx
│ │ └── index.tsx ■
│ ├── ssr.tsx
│ └── utils
│ ├── JyankeFunction.ts ■
│ └── JyankeType.ts ■
├── app.config.ts
├── package-lock.json
├── package.json
├── scores.txt
└── tsconfig.json
1. index.tsx
メインのコード
- ① Getting Startedで説明したようにルーティングの設定
- ここでジャンケン結果を取得
- ② ジャンケンの勝敗決定と記録はServer Functionで行います
// Bug?
に付いては後で説明します
import { createFileRoute, useRouter } from '@tanstack/react-router';
import ScoreBox from '../components/ScoreBox';
import JyankenBox from '../components/JyankenBox';
import { getScores, ponScore } from '../utils/JyankeFunction';
import { Te } from '../utils/JyankeType';
export const Route = createFileRoute('/')({ // ← ①
component: Home,
loader: async () => await getScores(),
})
function Home() {
const router = useRouter();
const scores = Route.useLoaderData();
const pon = async (human: Te) => { // ← ②
await ponScore(human + 1); // Bug?
router.invalidate();
}
return (
<>
<h1>じゃんけん ポン!</h1>
<JyankenBox pon={pon} />
<ScoreBox scores={scores} />
</>
);
}
2. JyankeFunction.ts
Server Function
- ① 対戦結果をファイルから読み込む関数
- 対戦結果はファイルにはJSON形式で書かれています
- ファイルが存在せずエラーになったら
[]
を戻します
- ② 対戦結果をファイルに書き込む関数
- ③
readScores()
をServer Functionとして定義 - ④ ジャンケンを行い
writeScores()
でファイルに書き込む- Server Functionとして定義しています
// Bug?
に付いては後で説明します
import { createServerFn } from '@tanstack/start';
import * as fs from 'node:fs';
import { ScoreType, Te } from './JyankeType';
const filePath = 'scores.txt';
async function readScores(): Promise<ScoreType[]> { // ← ①
try {
const jsonString = await fs.promises.readFile(filePath, 'utf-8');
return JSON.parse(jsonString);
} catch {
return [];
}
}
async function writeScores(score: ScoreType) { // ← ②
const scores = await readScores();
const jsonString = JSON.stringify([score, ...scores]);
await fs.promises.writeFile(filePath, jsonString);
}
export const getScores = createServerFn('GET', () => { // ← ③
return readScores();
})
// ↓ ④
export const ponScore = createServerFn('POST', async (human1: Te) => {
const human = human1 - 1; // Bug?
const computer = Math.floor(Math.random() * 3);
const judgment = (computer - human + 3) % 3;
const score = {human, computer, judgment} as ScoreType;
writeScores(score);
})
3. JyankeType.ts
いつものジャンケン・アプリで使う型の定義
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;
};
4. JyankenBox.tsx
これも、いつものグー・チョキ・パー ボタンのコンポーネント
import { Te } from "../utils/JyankeType";
type JyankenBoxProps = {
pon: (te: Te) => void
}
export default function JyankenBox ({pon}: JyankenBoxProps) {
const divStyle: React.CSSProperties = {margin: "0 20px"};
const buttonStyle: React.CSSProperties = {margin: "0 10px",
padding: "3px 10px", fontSize: 14};
return (
<div style={divStyle}>
<button onClick={() => pon(Te.Guu)} style={buttonStyle}>グー</button>
<button onClick={() => pon(Te.Choki)} style={buttonStyle}>チョキ</button>
<button onClick={() => pon(Te.Paa)} style={buttonStyle}>パー</button>
</div>
);
}
5. ScoreBox.tsx
これも、いつものジャンケン対戦の結果表示コンポーネント
import { ScoreType } from "../utils/JyankeType";
type ScoreBoxProps = {
scores: ScoreType[]
}
export default function ScoreBox ({scores}: ScoreBoxProps) {
const teString = ["グー","チョキ", "パー"];
const judgmentString = ["引き分け","勝ち", "負け"];
const tableStyle: React.CSSProperties = {marginTop: 20, borderCollapse: "collapse"};
const thStyle: React.CSSProperties = {border: "solid 1px #888", padding: "3px 15px"};
const tdStyle: React.CSSProperties = {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>
</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>
</tr>
)}
</tbody>
</table>
);
}
バグなのだろうか?
案外と順調に動きましたが、何故かグーボタンを押すと結果に表示されません! チョキ、パーは問題ありません。デバッグして行くとチョキ = 1
、パー = 2
はその値が正しくServer Functionに伝わるのですが、グー = 0
はServer Functionにはnull
として渡ってしまいます。
何か設定が足りないのでしょうか? それともTanStack Startのバグ? 0がnullになるのはJavaScriptならありそうですね。😅
今回は追求は諦め、呼出し側で+ 1
して受け取り側で- 1
する事で対応しました。0
を送らなければ良いようです。
まとめ
Alpha版とはいえ、Getting Startedやドキュメントの完成度が高いのに好印象を受けました。
さて、今回いつもの自前アプリを作ってみてTanStack Startのコンセプトが少しわかった気がします。
コンセプトの一部をDeepL翻訳してみました、
- SSR, STREAMING AND SERVER RPCS : リッチでインタラクティブなアプリケーションは、すべてを持っていることができないと誰が言いましたか?TanStack Start には、フルドキュメントの SSR、ストリーミング、サーバー機能、および RPC のための強力な機能が含まれています。もう、サーバーサイドレンダリングとトップクラスのクライアントサイドインタラクティブのどちらかを選ぶ必要はありません。サーバーを思いのままに操ることができます。
- CLIENT-SIDE FIRST, 100% SERVER CAPABLE : 他のフレームワークが、私たちが長年フロントエンドコミュニティとして培ってきたクライアントサイドのアプリケーションエクスペリエンスに妥協し続ける一方で、TanStack Startは、ユーザーエクスペリエンスに妥協させないフル機能のサーバーサイド対応システムを提供しながら、クライアントサイドの最初の開発者エクスペリエンスに忠実であり続けます。
React19(Next.js 15)は、Server Componentを強調しているように思えます、コンポーネントのデフォルトが"use server"
なのが1つの表れです。
しかしTanStack Startは、今までのReactのようにクライアントサイドの能力を高めるフルスタック・フレームワークを目指しているようです。
Remixもそうですが、Next.jsとは異なるコンセプトのフレームワークが出てくるのは、プログラマーにとって選択肢が増え良いことだと思います❗