tanstack-frontend について
React と TanStack Start を使うフロントエンド開発者向けのスキルです。FSD のレイヤー分け、コンポーネントやデータ層の責務、TanStack Query の利用規約を示します。
主な機能
- 01TanStack Start のルーティングとサーバー関数の規約
- 02FSD のレイヤーとディレクトリ指針
- 03コンポーネント、フック、モデルの責務
- 04クエリとミューテーションのファクトリーパターン
- 05ビューモデルとリクエスト状態の指針
活用シーン
- 01ルート、ページ、機能の境界を設計
- 02TanStack Query によるデータ取得と更新の実装
- 03リクエスト状態をページ用ビューモデルに整理
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ alexander-zuev/ agent-skills --skill tanstack-frontendインストール後、Claude Code で/tanstack-frontendを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/data-access.mdperformance.mdphone-surfaces.mdtanstack-router-query.mdvisual-design.md詳細
フロントエンド — React + TanStack Start
このスキルでは、フロントエンドの配置、React、TanStack Query、状態管理、UI の規約を扱います。 型、スキーマ、モジュール、コメント、一般的なテストについては、typescript-standards を使用してください。 スタブを含むサーバー関数を追加する場合は、cloudflare-backend を使用してください。
[detect: …] と記されたセクションは、確認する証拠を示しています。該当する懸念点を示す参考コードを選んでください。
リポジトリの古さから互換性を推測しないでください。エンティティ、クエリ、ミューテーションに関する作業の前に、データアクセス を読んでください。
編集前に、対象プロジェクトのファクトリー、サーバー関数、ミドルウェア登録、エラーペイロードを追跡してください。
選択したレイヤー、成功時/エラー時の契約、ソースファイル、重要な競合を明示してください。
TanStack Start
- TanStack Start を使用し、Next.js は使用しないでください。従来型の SSR +ハイドレーションを使用し、RSC や
'use client'は使用しません。 - サーバー専用コードには
createServerFn()を使用します。これはsrc/server/entrypoints/functions/に置く RPC スタイルのサーバー関数です。 - ローダーはアイソモーフィックで、サーバーとクライアントの両方で実行されます。シークレットや DB へのアクセスにはサーバー関数を使用します。
- 必要に応じて保留時間の設定を使用します。人工的な遅延ではなく、ネイティブのルートオプションを使用してください。
pendingMs: 0(保留状態を即座に表示。デフォルトは 1000ms)、pendingMinMs: 500(ちらつきを防止)。ほとんどのルートでは、ローダーでシードされたデータとpendingComponentを利用します。
アーキテクチャ
考え方
FSD は Feature-Sliced Design の略です。エンティティは、ユーザー、通話、残高などのビジネス概念を表します。 フィーチャーは、それらの概念を連携させるユーザー操作を表します。エンティティスライスは画面のワークフローを所有しません。
Route → Page → Feature → Entity → Shared
↘ Shared UI
インポートは下位レイヤーを参照します。再利用可能なフィーチャーが不要な場合、ページは共有 UI とエンティティを組み合わせられます。
プロジェクトでは Shared を ui/、lib/、ブラウザーセーフなワークスペースパッケージに分割しています。
ディレクトリ
apps/<app>/src/ 配下の共有コア:
src/
├── routes/ # TanStack Router files — thin, delegate to pages/
├── pages/ # orchestrators
├── entities/ # business concepts: types, factories, pure derivations
│ └── <entity>/ # <entity>-queries.ts, <entity>-mutations.ts, domain files
├── features/ # user actions and reusable workflows
│ └── <feature>/
│ ├── components/
│ ├── hooks/
│ ├── queries/ # feature-owned data, when no entity owns it
│ └── models/ # or store/, schema/ as needed
├── ui/ # app-specific presentation + stylesheets
├── lib/ # clients (rpc, auth, sentry), router, shared hooks
└── server/ # backend half (see cloudflare-backend skill)
[detect] 既存のエンティティスライスは、フラットな構成の場合も、model/ や api/ などの FSD セグメントを使用する場合もあります。
プロジェクトのレイアウトに従ってください。FSD の図に合わせるだけのために、セグメントやバレルを導入しないでください。
古いプロジェクトではモデルがフィーチャーまたは lib 配下に置かれています。無関係なコードを再構成しないでください。
[detect] デザインシステムの配置先:複数のアプリで使用する共有 packages/ui ワークスペースパッケージ(プリミティブとトークンのスタイルシートのみ)、および/またはアプリローカルの src/ui/components/。アプリローカルのコンポーネントは、ドメイン別(auth/、media/)または種類別(controls/、overlays/、feedback/ など)に整理できます。既存のフォルダーに従い、分類体系を新たに作らないでください。
レイヤールール
- ルートは薄いアダプターです。ルートを設定し、ページをレンダリングします。
- ページはフィーチャーを組み合わせます。インラインサービスは置きません。マーケティング/ランディングページでは、ローカルの
sections/およびcomponents/サブツリーを持つことができます。 - フィーチャーは操作のワークフロー、コンポーネント、フックを所有します。エンティティは再利用可能なビジネスデータと、そのクエリ/ミューテーションファクトリーを所有します。
- UI コンポーネントは純粋でなければなりません。API 呼び出し、ビジネスロジック、フィーチャーのインポートを含めないでください。
- ビジネスデータは、それを所有するエンティティに配置します。表示コードをローカルに置くか共有するかは、再利用回数を基準に判断します。
✅ page → feature · feature → entity/model · feature → ui · entity → lib · hook → query factory
❌ ui component → feature · entity → feature · page → direct API call · circular feature pairs
エンティティスライスは、同階層の別のエンティティスライスをインポートしてはいけません。フィーチャースライスは、同階層の別のフィーチャースライスをインポートしてはいけません。
関連付けは、それを利用するフィーチャーまたはページで調整してください。適切な場合は、共有コントラクトをブラウザーセーフなコアパッケージに置いてください。
プロジェクトですでに採用されており、ドメイン上の関係が必要とする場合に限り、FSD の明示的な @x エンティティ API を使用してください。
エンティティの境界を追加または変更する前に、公式の FSD レイヤー のガイダンスを読んでください。
アプリのページは 100 行以内にしてください。マーケティングページはこれを超えてもかまいませんが、作業範囲に含まれない限り再構成しないでください。
コンポーネントアーキテクチャ(Fowler のレイヤー)
- コンポーネント — JSX は薄く保ち、props/ハンドラーのみを扱います。100 行未満(目標は 50 行)です。
- フック — 状態/エフェクト、2 つ以上のクエリをドメインオブジェクトに統合する処理、またはプレゼンテーションモデル(以下を参照)を扱います。1 つのクエリと呼び出し側のオプションをそのまま渡すだけのフックは、単なるパススルーです。その場合は、ファクトリーまたはバインド済みのルートコンテキストオブジェクトで
useSuspenseQuery/useQueryを直接呼び出し、追加オプション(select、staleTime)はその呼び出し側に置きます。複数の呼び出し元で共有するルール(ポーリングするタイミングなど)を担う場合は、1 つのクエリをラップすることにも意味があります。その目的に沿った名前を付け、クエリ結果は変更せずに返すことで、呼び出し元がisPending、isError、dataを引き続き利用できるようにします。フックの引数としてUseQueryOptionsを受け取らないでください。型推論が機能しなくなります。出典:The Query Options API — クエリをそのまま渡すだけなら、ラッパーフックは「少し無意味に思えます」。
// ✅ extra options at the call site
const { data } = useQuery({
...productQueries.detail({ productId: id }),
select: (product) => product.title,
staleTime: 20_000,
})
// ❌ do not wrap the factory and forward UseQueryOptions
function useProduct(id: ProductId, options?: UseQueryOptions<Product>) {
return useQuery({
...productQueries.detail({ productId: id }),
...options,
})
}
- モデル — フレームワークに依存しないビジネスロジックです。React への依存はゼロです。
- サービス/クエリ — データの取得と、API とドメイン間の変換を担います。ドメインのマッピングは
queryFnに置きます。ビューのマッピングは別の役割です。プレゼンテーションモデルを参照してください。
プレゼンテーションモデル
ルートはフックを読み込み、その結果を下位に渡します。クエリの状態を props にマッピングするのは配線ではありません。画面のビューモデルを所有するフックに置きます。1 つの画面だけが使用する場合は pages/<page>/use-<thing>.ts、2 つ以上の画面や機能で使用する場合は features/<feature>/hooks/ に置きます。entities/ には型、クエリファクトリー、純粋な派生処理を置き、React をインポートすることはありません。
ドメインモデル、データ層、フック
出典:Modularizing React Applications(Fowler、Qiu)。各レイヤーの役割はそれぞれ1つです。
| レイヤー | 役割 | このプロジェクトでの配置 |
|---|---|---|
| ビュー | 描画してコールバックを呼び出します。データの取得元は把握しません。 | components |
| フック | 「ビューの背後にあるステートマシン」です。状態を保持し、イベントを受け取り、データ層を呼び出して、ドメインオブジェクトとコールバックをビューに渡します。 | features/*/hooks/、pages/*/use-*.ts |
| ドメインモデル | 型、純粋関数、またはゲッターを持つクラスとして表現される、ドメインデータに関するビジネスルールです。「ビューの存在を認識せず」、サーバー上でも実行できます。 | entities/ |
| データ層 | レスポンスを取得し、ドメインオブジェクトに変換します(「取得して変換」)。これにより、外部データの形が変わっても変更箇所を1つのファイルに保てます。 | クエリおよびミューテーションのファクトリー(queryFn、mutationFn) |
ここから導かれるルールです。この記事では、取得したデータから作られ、UIデータを含まないドメインオブジェクトを扱っています。ローディング状態やエラー状態は扱っていないため、リクエスト状態に関するルール1と2は、このプロジェクト独自のルールです。
1つのクエリを表示するコンポーネントは、そのクエリを直接読み取り、isPending、isError、data に応じて分岐します(「非同期状態のレンダリング」を参照)。リクエスト状態がドメイン上の事実になるのは、それがエンティティに反映される場合だけです。
- ドメインモデルが受け取るのはドメイン値だけです。 エンティティの型やクラスが、クエリやミューテーションの形をそのまま、または再定義して受け取ることはありません。
status、variables、data、error、isPending、refetchは含めません。これらはデータの受け渡し方法を表すものであり、ビジネス上の意味を表すものではありません。 - データ層がペイロードを変換し、フックがリクエスト状態を変換します。
queryFnはレスポンスをドメインオブジェクトに変換します。フックはエンティティを作る前に TanStack の状態をドメイン上の事実に変換します。data → AdmittedCall、error → refused、status === "pending"→ まだ承認済みの呼び出しはありません。 - フックが結合し、ドメインモデルが判断します。 複数のソース(クエリ、ミューテーション、SDK、ローカル状態)から1つの概念を構成する場合、フックはそれらのドメイン値を1つのエンティティに渡します。状態や権限などのルールはエンティティが導出します。フック自体はビジネスルールを持ちません。
- ドメインクラスは不変です。 フックは現在の値からレンダリング中にクラスを構築します。インスタンスをその場で更新したり、購読したりすることはありません。
// ❌ mapping in the route — 20 lines, still wrong
const { data, error, hasNextPage, fetchNextPage, refetch } = useInfiniteQuery(conversationQueries.list())
const conversationList = data
? { status: 'ready', conversations: data.pages.flatMap((p) => p.conversations), hasMore: hasNextPage, … }
: { status: 'failed', error, onRetry: () => void refetch() }
// ✅ pages/conversations/use-conversation-list.ts returns the view-ready shape
export function useConversationList(): ConversationList {
const query = useInfiniteQuery(conversationQueries.list())
if (query.status === 'pending') return { status: 'pending' }
if (query.status === 'error') {
return { status: 'failed', error: query.error, onRetry: () => void query.refetch() }
}
return { status: 'ready', conversations: query.data.pages.flatMap((p) => p.conversations), … }
}
これはスタイルの問題ではなく、具体的な理由が2つあります。
statusではなくdata/errorで分岐すると、pendingを取りこぼします。 ❌ の例では、正当なローディング状態の間に「失敗」と表示されます。3つすべての状態を区別できるのはstatusだけです。- ページはフィクスチャから描画できる状態を保てます。 すべての状態がストーリーから渡せる値になります。「失敗」も同様です。そうでなければ、失敗するフェッチをモックして意図的に発生させる必要があります。
戻り値は、判別可能なユニオン型1つです。各アームには、そのアームで使えない値を持たせないよう、それぞれのフィールドを定義します。
export type ConversationList =
| { readonly status: 'pending' }
| { readonly status: 'ready'; conversations: readonly ConversationSummary[]; hasMore: boolean; isLoadingMore: boolean; onLoadMore: () => void }
| { readonly status: 'failed'; error: unknown; onRetry: () => void }
再利用可能なエンティティ状態型はエンティティのスライスに置きます。画面固有のプレゼンテーション型は、そのページのフックの隣に置きます。 エンティティにページの型をインポートしてはいけません。このプロジェクトでは、エンティティのモジュールに React のインポートを含めません。FSD 自体は、エンティティでの UI を許可しています。
ページの Props
onXProps をばらばらに渡すのではなく、ハンドラーを 1 つのactionsオブジェクトにまとめます。そうすれば 1 つ追加しても、変更が必要なのは呼び出し箇所 1 つだけで、すべてのストーリーやハーネスではありません。- ページがすでにスロットに必要なものを持っている場合は、
ReactNodeスロットではなくデータを渡します。header: ReactNodeという Prop が常に<Header host={host} relay={relay} />をラップするだけなら、hostを渡し、ページ側でヘッダーを描画します。 - ナビゲーションにはコールバックではなく、必ず
Linkを使います。onOpenThing: (id) => navigate(...)では、cmd/中クリック、「新しいタブで開く」、右クリックからの「アドレスをコピー」、ホバー時の URL が失われます。useNavigateは命令的なケース、つまりどこかへ移動するときではなく、ミューテーション後に使います。
| 指標 | 上限 | 対応 |
|---|---|---|
| 行数 | <100(目標 50) | コンポーネント/フックを切り出す |
| 使用するフック数 | ≤5 | カスタムフックを切り出す |
| Props 数 | ≤7 | コンポジションまたはコンテキストを使う |
| 三項演算子のネスト | 1 | 早期リターンを使う |
リファクタリングの手法:フックの切り出し(状態/副作用を分離)、モデルの切り出し(計算を純粋な TS に移す)、サービスの切り出し(API 呼び出しを分離)。アンチパターン:肥大化したコンポーネント、万能フック、JSX 内のロジック、フレームワークへの密結合、サービスとしてのストア。
データレイヤー — クエリーとミューテーションのファクトリー
各エンティティは、再利用可能な queryOptions、infiniteQueryOptions、mutationOptions ビルダーを提供します。
ファクトリーがキー、型付きリクエスト、ドメイン変換、キャッシュへの作用を担います。コンシューマーフックは画面の状態とアクションの順序を担います。
例、リファレンスファイル、API チェック、検証については、データアクセスを参照してください。
現在のトランスポートパターンでは、サーバー関数の値をそのまま返します。 調査したサーバー関数がエンベロープを返す場合にのみ、既存のアンラップヘルパーを使います。 シリアライズとエラー変換はバックエンドスキルが担います。
ルートでプリフェッチするクエリーでは、ルート側で URL 入力を一度だけバインドします。 ローダーとページは、同じ返却済みクエリーオプションを使います。
クエリーキー
- キーには、
queryFnが読み取る入力をすべて含めます。再取得するにはキーを変更します。refetch({ newId })は呼び出さないでください。 - 階層:
['products']→['products', 'list']→['products', 'list', filters]→['products', 'detail', input]。 - プレフィックスで無効化します:
invalidateQueries({ queryKey: ['products', 'list'] })。 useQueryとuseInfiniteQueryでキーを共有しないでください。- 型付きの
getQueryData/setQueryDataにはproductQueries.detail({ productId: id }).queryKeyを使います。
変換(select)
| 場所 | 内容 |
|---|---|
queryFn | 必要に応じて、トランスポート値からドメイン値に変換します。キャッシュにはこの値が保存されます。 |
呼び出し箇所の select | 単一のフィールド、または派生値です。ファクトリーには select を含めないでください。 |
| レンダリング | コストの低い派生処理のみを行います。 |
select をデフォルトで使わないでください。大きなペイロードによって小さなウィジェットが再レンダリングされる場合に使います。
ミューテーション
ミューテーションは <entity>-mutations.ts に置きます。
- 成功後は、デフォルトで
invalidateQueriesを使います。 - リストの更新が終わるまでボタンを保留状態にする必要がある場合は、その Promise を返します。
mutateを優先します。呼び出し側で返された Promise を合成または待機する必要がある場合はmutateAsyncを使います。- ファクトリーの
onSuccessではキャッシュ処理を行います。呼び出し箇所のmutate(variables, { onSuccess })ではトースト/リダイレクトを行います。 - 変数オブジェクトは 1 つにします:
mutate({ title, body })。 - フォーム、ダイアログ、リダイレクトでは、楽観的なキャッシュ更新を行わないでください。即時切り替えの場合にのみ使います。ミューテーション後の無効化を優先してください。
setQueryDataを使う例外は、即時切り替えの場合、またはミューテーションがキャッシュ内の行と完全に同じものを返す場合です。
無限クエリー
infiniteQueryOptions を使います。通常のリストキーとは異なるキーを設定します。initialPageParam を設定します。
ルーターとクエリの責務
- Entity:クエリファクトリー、クエリキー、リクエスト、ドメイン変換を所有します。Router API はインポートしません。
- Route:URL の状態を検証し、現在のルートマッチに対するすべてのクエリ入力をバインドします。
- Loader:バインド済みのクエリオプションでキャッシュ処理を開始します。スローするか握りつぶすかは、好みではなく UX に関する判断です。
queryClient.query(options)を await します(キャッシュデータが古くても優先する必要がある場合は、query({ ...options, staleTime: 'static' })を使用します)。スローすると、errorComponentによってルート全体が置き換えられます。.catch(noop)で握りつぶすと、失敗はページレイアウト内にとどまります。その場合、suspense がカバーしなくなるため、コンポーネントもpendingを管理します。シェル(ヘッダー、ナビゲーション、アクション)を読み込み失敗後も維持する場合は、握りつぶす方法を選びます。ensureQueryData/prefetchQuery/fetchQueryは非推奨のエイリアスであり、Query v6 で削除されます。 - Page:
useQueryまたはuseSuspenseQueryでバインド済みのクエリを監視します。 - Feature:ドメインデータを利用し、ユーザーアクションを所有します。
Router と React は同じ QueryClient を使用します。Query が鮮度を管理する場合は、defaultPreloadStaleTime: 0 を設定します。
SSR のリクエストごとに新しい QueryClient を作成します。デハイドレーション、ハイドレーション、ストリーミングには setupRouterSsrQueryIntegration を設定します。
Query が所有するデータは Query フック経由で読み取ります。useLoaderData 経由では読み取りません。
バインド、ローダーのタイミング、境界、サブスクリプション、例、ソースについては、TanStack Router + Query をお読みください。
ページでのルートパラメーターとコンテキスト
ルートで検証、絞り込み、または変換が必要な場合は、ルート定義の params.parse でパスパラメーターを解析します。解析済みの値を返し、子ルートに継承させます。パーサーは決定的で、副作用のないものにします。
ページでは getRouteApi('/_authed/products/$productId') を使って、パラメーターとローダーによって設定されたコンテキストを読み取ります。ルートファイルから props をバケツリレーする必要はありません。検索パラメーターは、validateSearch(および @tanstack/zod-adapter)を介して Zod で検証します。
状態管理
| 状態の種類 | 解決策 | 用途 |
|---|---|---|
| サーバーデータ | TanStack Query | 常に使用します。例外はありません。 |
| ローカル UI | useState | 単一コンポーネント |
| グローバルなクライアント状態 | Zustand | 以下の基準を満たす場合 |
| 派生状態 | インラインで計算 | 計算できるものは保存しません |
サーバー状態
すべてのサーバーデータは Query を通します。useEffect+fetch は使用せず、クエリ結果をストアにコピーしません。クライアントキャッシュへの書き込み(queryClient.setQueryData)は、上記のミューテーションに関する例外で使用するものであり、ローカル UI 状態のためには使用しません。
ウォーターフォールを避ける: サーバー関数は、ページに必要なデータを返すように設計します(サーバー側で結合し、N+1 を避けます)。複数のリクエストが避けられない場合は、Promise.all を使います。staleTime を引き上げて、繰り返しのフェッチを減らします。フォーカス時の再フェッチはデフォルトのままにします。変更するのは、文書化されたプロダクトの動作に必要な場合のみです。refetchOnMount が許可している場合、古くなったクエリはマウント時に再フェッチされます。デフォルトは staleTime: 0 と refetchOnMount: true です。
Zustand
以下の場合にのみ使用します:(1)複数のコンポーネントで同じクライアント状態が必要、(2)永続化が必要、(3)React ツリーの外部からアクセスする必要がある。それ以外の場合は useState を使うか、状態を上位に移します。実際のアプリに必要なストアはごくわずかです(現在のリポジトリでは 0~2 個)。新しいストアの導入はデフォルトではなく、設計上の判断として扱います。
ルール:
- アトミックセレクターを使います(
useShallowを使わず、レンダーごとに新しい{}を作成しない) - O(1) の検索を使います。セレクター内で
.find()を使うのではなく、検索用マップを使います。 - データと
actionsサブオブジェクトを分けます。 - サーバーデータは決して保存しません。
// ❌ new object every render → re-renders on ANY change
const { x, y } = useStore((s) => ({ x: s.x, y: s.y }))
// ✅ atomic
const x = useStore((s) => s.x)
const y = useStore((s) => s.y)
// ❌ O(n) per render // ✅ O(1)
useStore((s) => s.nodes.find((n) => n.id === id))
useStore((s) => s.nodeLookup[id])
Vanilla ストアのバリエーション — React 以外のコードからストアを使う必要がある場合(アップロードエンジン、バックグラウンドタスク):createStore(vanilla)+ zustand/react の useStore + useShallow、devtools ミドルウェア、actions サブオブジェクトを使用します。リアクティブでないハンドル(エンジンインスタンスなど)は状態内ではなく、ストアと並ぶモジュールレベルの Map に格納します。
エフェクト
エフェクトは、セットアップ → クリーンアップ → セットアップの順で実行されても正しく動作する必要があります。 ref やフラグを使って Strict Mode の再実行を抑制してはいけません。React の再実行に関するガイダンスをお読みください。 購入を含むユーザー操作は、発生元のイベントハンドラーに記述します。 認証後または外部へのリダイレクト後に自動的に処理を続行する場合は、リダイレクト前に安定した試行 ID を永続化します。 再実行されても安全なように、サーバーまたは外部との境界で冪等性を保証します。
リンターでこれを強制します。react/set-state-in-effect、react/no-deriving-state-in-effects、および9つの react-you-might-not-need-an-effect/* ルールは error です。各行はエフェクトが不要な場合の一節で、中央の列にはまとめの文を引用しています。
| エフェクトの処理 | react.dev の説明 | 使用するもの |
|---|---|---|
| props/state から値を計算する | 「レンダー中に計算できるなら、エフェクトは必要ありません。」 | レンダー中に導出する |
| コストの高い値をキャッシュする | 「コストの高い計算をキャッシュするには、useEffect の代わりに useMemo を追加してください。」 | useMemo |
| prop に応じてすべての state をリセットする | 「コンポーネントツリー全体の state をリセットするには、異なる key を渡してください。」 | key |
| prop に応じて一部の state を調整する | 「prop の変更に応じて特定の state をリセットするには、レンダー中に設定してください。」 | レンダー中の if (prev !== next) setState(...) |
| ユーザー操作に反応する | 「コンポーネントが 表示された ために実行されるコードはエフェクトに、それ以外はイベントに記述します。」 | イベントハンドラー |
| setState の後に setState を連鎖させる | 「複数のコンポーネントの state を更新する必要がある場合は、1つのイベント中にまとめて更新するほうが適切です。」 | 1つのハンドラーまたは reducer イベント |
| アプリのセットアップを一度だけ実行する | コンポーネントごとではなく、モジュールレベルで実行します | モジュールスコープ |
| state を親に通知する | 「異なるコンポーネント間で state 変数を同期しようとする場合は、state を親に引き上げることを検討してください。」 | state を引き上げ、ハンドラー内で onChange を呼び出す |
| ストアまたはブラウザー API を購読する | この用途専用の組み込みフックがあります | useSyncExternalStore |
| サーバーデータを取得する | 「エフェクトでデータを取得できますが、競合状態を避けるためにクリーンアップを実装する必要があります。」 | TanStack Query(サーバー状態を参照) |
| DOM ノードを測定する | コールバック ref はアタッチ時に実行され、デタッチ時にクリーンアップされます | ref={useCallback((node) => {...; return cleanup}, [])} |
| クリーンアップを伴って外部システムと同期する | これがエフェクトの用途です | useEffect。setState は非同期コールバックからのみ呼び出す |
// ❌ redundant state + sync effect
const [filtered, setFiltered] = useState([])
useEffect(() => { setFiltered(items.filter((i) => i.active)) }, [items])
// ✅ derive inline
const filtered = items.filter((i) => i.active)
非同期状態のレンダリング
相互排他的な状態は、それぞれ別の分岐にします。ネストした三項演算子は使わないでください。
| データソース | パターン |
|---|---|
| ルートローダー(クリティカル) | pendingComponent / errorComponent の設定 |
コンポーネント内の useQuery | 早期リターン |
| 複数の非同期ソース | Suspense と ErrorBoundary の組み合わせ |
function Component() {
const { data, isPending, isError } = useQuery(...)
if (isPending) return <Layout><Skeleton /></Layout>
if (isError) return <Layout><Error /></Layout>
return <Layout><Content data={data} /></Layout>
}
クエリの描画と境界
await するローダークエリは、重要なデータの読み込み中にルートをブロックします。await しないクエリは、Suspense を使ってストリーミングするか、useQuery を使ってノンブロッキングのままにできます。
ルートクエリでは、Router の保留状態とエラー境界を使用します。スタンドアロンのウィジェットでは、独自のローカル Suspense とエラー境界を持たせることができます。
getRouteApi または厳密な from ルートを使用します。必要最小限の Router 状態を購読するには select を使用します。
ルートのプリフェッチまたは Query の SSR の動作を変更する前に、TanStack Router + Query をお読みください。
ポーリング
TanStack Query の refetchInterval を使用し、別のインターバルループを追加しないでください。
有限のジョブでは、終端状態になったら停止します。完了期限があるジョブには、プロジェクトのタイムアウトポリシーを使用します。
通話中の残高などのライブデータでは、ポーリングを有効にするタイミングは、それを使用する機能が制御します。
継続的な読み取りには終端状態がないため、独自のスタックジョブ用タイムアウトを設ける必要はありません。
ポーリング間隔は、プロダクト要件に基づいて決定します。プロダクト要件で変更が必要な場合を除き、Query のバックグラウンド動作とフォーカス時の動作を維持します。
ボタンの保留状態/読み込み状態
状態が変わっても、ボタンのサイズとラベルの位置を安定させます。次の3つのパターンのいずれかを使用します。
- ラベルのスペースとアクセシブルネームを維持して、スピナーのみを表示する
- テキストは変更せず、左側の確保したスペースにスピナーを表示する
- テキストは変更せず、ボタンを無効化するだけにする
スピナーによってラベルが移動したり、ボタンのサイズが変わったりしないようにしてください。
リストと反復要素のパフォーマンス
新しいアプリでは React Compiler(babel-plugin-react-compiler)が有効です [detect vite.config.ts]。メモ化は Compiler が処理するため、Compiler の対象となるケースで useMemo/useCallback を手書きしないでください。構造上のコストは依然として積み重なるため、10件以上の反復要素を描画する場合は、次の点に注意してください。
- 要素ごとの負荷を最小限にする — 1行につきコンポーネントは1~2個にし、DOM ノードを確認して、不要なラッパー
<div>を削除します。 - イベント委譲:要素が100件以上、または要素ごとのハンドラーが3つ以上ある場合は、行ごとにクロージャを作らず、コンテナに1つだけハンドラーを置き、
data-item-idを読み取ります。 - 複雑な処理は操作時まで遅らせる — 行ごとにメニュー/ツールチップをマウントせず、アクティブな行に対して必要なときだけ1つ描画します。
const [menuTarget, setMenuTarget] = useState<string | null>(null)
{items.map((item) => (
<Row key={item.id} onContextMenu={() => setMenuTarget(item.id)} />
))}
{menuTarget && <ContextMenu itemId={menuTarget} />}
- リスト項目内で
useEffectを使わない — 件数に応じてエフェクトが増えるため、コンテナ側に移します。 - 200件以上の場合は仮想化する(TanStack Virtual)。それ未満では使用しません。
- CSS:反復要素にはクラスベースのフラットなセレクターを使用し、
:has()は避けます(親ごとに子要素を評価するため)。
デザインシステム
構成:Tailwind CSS v4(@theme)+ @radix-ui/colors のスケール + shadcn スタイルのコンポーネント(cva バリアント、tailwind-merge)を Base UI/Radix のプリミティブ上で使用します。
- トークンはスタイルシートに集約します:
ui/stylesheets/{colors,typography,radius,shadows,utilities}.css(複数のアプリで共有する場合はpackages/ui)。colors.cssは Radix のカラースケールをインポートし、shadcn 互換の変数名にマッピングします。コンポーネントはその名前に依存します。 [detect imports in ui components]プリミティブライブラリの使い分け:新しいコンポーネントでは@base-ui/react、古いコンポーネントでは@radix-ui/react-*を使用します。現在のファイルに合わせ、新しいコンポーネントではリポジトリ内で多数派のライブラリに従います。- コンポーネントのスタイルを CSS ファイルに書かない — コンポーネントはトークンを使用する TSX にします。
- トークンが見つからない場合:まずスタイルシートを確認し、その後デザインシステムを拡張します。個別の値やクラスを場当たり的に追加しないでください。
- 既存のデザインシステムのコンポーネント、アイコン、パターンを作り直さないでください。
UI の文言とアイコン
UI の文言には末尾の句点を付けません。対象はラベル、ボタン、ヒント、バリデーションエラー、トースト、リード文、空状態です。
2つの節をつなぐ場合はコンマを使うか、1つの節にします。
メール、法務ページ、記事、SEO の説明では、文末の句読点を使用します。
プロジェクトのローカライズシステムに従います。
Phosphor アイコンは PlusIcon や MagnifyingGlassIcon のように、Icon サフィックス付きでインポートします。
データ不足やエラー状態を隠すために || を使用しないでください。型を見直すか、入力をパースします。
妥当なプロダクトのデフォルト値は引き続き使用できます。
Phone surfaces
スマートフォン向け製品、PWA、スマートフォンのフローについては、スマートフォン向けサーフェスを参照してください。 これらのルールは、デスクトップや複合サーフェスには自動的に適用されません。
ブラウザーのサポート
基準となるのは、スタックがビルド対象とする最も古いブラウザーです。Tailwind CSS 4 と Vite 8 によって、Safari と iOS 16.4、Chrome 111、Firefox 128 が基準として定められています。プロジェクトの .browserslistrc に記録されています。基準を下回るブラウザー向けにはビルドしないでください。
コードは基準となる環境で動作する必要があります。
- 新しいプラットフォーム API は、記憶に頼らず MDN browser-compat-data で確認してください。
URL.parseは Safari 18 が必要なため、iOS 16 と 17 でサインイン時にクラッシュしました。 - 基準より新しい API には、ポリフィル、または
// FALLBACK: <who gets what>と記されたフォールバック分岐が必要です。 - 基準を下回る環境では、アプリにアップデート画面を表示し、起動しないようにしてください。
[detect] browser-support/no-unsupported-api lint ルールは、プロジェクトで利用可能な場合、項目 1 と 2 を適用します。基準を下回る環境へのサポートを求めるレビュー指摘は却下してください。
ビジュアルデザイン
タイポグラフィ、間隔、サーフェス、インタラクションのルールについては、ビジュアルデザインを参照してください。
Storybook
- フレームワークパッケージ:
@storybook/tanstack-react—Meta/StoryObjはここからインポートします。型付きメタ:const meta: Meta<typeof Component> = {...}。 - ストーリーは
.storybook/配下に集約します(design-system/、components/、features/、pages/)。.storybook/を基準に検出され、共通のタイトル定数は.storybook/story-paths.tsに置きます。データを扱うストーリーではmsw-storybook-addonを使用します。 - Storybook を優先する UI ワークフロー:新しい UI はアプリのコードを書く前にストーリーで試作します。ストーリーは UI の仕様です。UI を変更するたびに更新し、完了時にストーリーの URL を提示してください。
- ストーリーではローカライズシステムを使用しません。ストーリーファイルとストーリーデータでは、すべての文言にプレーンテキストを使用します。ストーリーにメッセージ関数をインポートしたり、ストーリー用のメッセージを追加したりしないでください。ストーリーが描画する本番用コンポーネントは、それぞれのメッセージを引き続き保持します。例外は、ロケール切り替えなど、国際化そのものを示すストーリーです。
Query テスト
テストごとに新しい QueryClient を使用してください。テストのデフォルト設定では retry: false にしてください。
ネットワーク境界をテストする場合は MSW を使用してください。実行可能な場合は、キャッシュを通じてファクトリーの動作をテストしてください。
変更した動作に応じたチェックについては、データアクセスを参照してください。
パフォーマンス目標
パフォーマンス関連の作業については、パフォーマンス目標を参照してください。
フォーム
[detect package.json]:
- TanStack Form(新しいアプリ)では、デザインシステムの
field.tsxプリミティブを使用します。 - react-hook-form +
@hookform/resolvers(古いアプリ)では、shadcn のform.tsxラッパーを使用します。
どちらの場合も、Zod スキーマを唯一のバリデーションソースとして使用し(サーバー関数の .validator と共有します)、デザインシステムのフィールドプリミティブを使い、場当たり的なフォーム状態管理は行いません。
サーバーデータを意図的にフォーム状態にコピーしたうえで、次のいずれかを選びます。
- 単独編集:クエリに
staleTime: Infinityを設定します。スナップショットを使用し、バックグラウンドで上書きされないようにします。 - 共同編集:Query をライブのまま維持します。未編集のフィールドにも更新が反映されるよう、
field.value ?? serverValueを表示します。
データが存在してからフォームを描画してください(defaultValues は定義済みである必要があります)。保存後は無効化を待ち、確定した保存値を取得してください。
- TanStack Form:
form.reset(savedValues)はフォームをリセットし、デフォルト値を更新します。 - React Hook Form:
reset(savedValues)は指定された値を使ってフォームをリセットします。可能な場合は、完全な値を渡してください。
TanStack Form のルール
参照元:shadcn TanStack Form、送信の処理、フォームの構成。
- フォームレベルで1つのスキーマを使って検証します:
validationLogic: revalidateLogic({ mode, modeAfterSubmission })とvalidators: { onDynamic: Schema }を設定します。フォームごとに検証のタイミングを選び、理由をコメントに記載します。フィールドバリデーターや、onSubmit内で手書きのチェックを追加しないでください。tanstackリントプラグインによってこれらのルールが適用されます。 defaultValuesの型にはz.input<typeof Schema>を指定します。onSubmitには入力値が渡されます。入力型と出力型が異なる場合はSchema.parse(value)を呼び出し、同じ場合はvalueを使います。- 時刻またはランダムなデフォルト値は、
useState(() => …)またはモジュール定数を使って一度だけ計算します。レンダー間でdefaultValuesが変わると、TanStack Form は未操作のフォームをリセットします。 - 各フィールドに
id={field.name}、name={field.name}、onBlur={field.handleBlur}を設定します。isInvalid = field.state.meta.isTouched && !field.state.meta.isValidで、data-invalid、aria-invalid、エラーを制御します。 - フォーム要素には
noValidateを設定し、event.preventDefault()を呼び出してからform.handleSubmit()を呼び出します。 - フォームコンポーネントは
pendingとonSubmit(output)を受け取ります。ミューテーション、トースト、ダイアログを閉じる処理は利用側が担当します。 - 非同期バリデーターには
onChangeAsyncまたはonSubmitAsyncを使います。同期スロットは Promise の完了を待ちません。 - サーバー関数にファイルを渡す場合は、
FormDataのみを使います。ミューテーションファクトリー内で作成してください。
認証(クライアント側)
better-auth クライアントは lib/clients/auth-client に配置し、lib/auth/ レイヤー(フック、モデル、サービス)でラップします。コンポーネントは useSession 形式のフックを利用し、生のクライアントを直接使いません。ルートグループ(_auth/_authed レイアウト)の beforeLoad で未認証ユーザーをリダイレクトし、ルートを保護します。