


そもそも、なぜ TanStack Router?
→ TanStack Router を採用
TanStack Routerの良さって?
- prefix コロケーション// パスも params も補完が効く・存在しないパスはコンパイルエラー
<Link to="/contracts/$contractId" params={{ contractId }} />
// 受け取る側も型付き(contractId: string)
const { contractId } = Route.useParams();Link / params が補完され、存在しないパスは コンパイルエラー// URL の ?page=2&tab=archived を schema で定義
export const Route = createFileRoute("/contracts")({
validateSearch: z.object({
page: z.number().default(1),
tab: z.enum(["active", "archived"]).default("active"),
}),
});
// 読む側は完全に型付き → URL がそのまま型安全な state に
const { page, tab } = Route.useSearch();routes/contracts/
├── index.tsx ← これだけが /contracts になる
└── -components/ ← `-` prefix で route tree から除外
├── Page.tsx
└── fallbacks/ ← ルート専用の物を隣に置ける構成、どうする?


→ 結果的に 3つ目(ハイブリッド) を採用
src/
├── features/<domain>/ ← api / hooks / components / Page まで集約
└── routes/<route>/
└── index.tsx ← features を読むだけの薄い wrapperPage まで features に集約、routes は読むだけの薄い wrapper- コロケーションなど TanStack Router の機能を活かしきれないsrc/routes/<route>/
├── -api/ ← queryOptions / mutationOptions
├── -hooks/ ← hook
├── -components/ ← View 断片 / fallbacks / Page
└── index.tsx ← loader / validateSearch- prefix で全部 routes に詰め込める → 機能はフル活用src/
├── features/<domain>/ ← ロジックの実体(Router 非依存)
│ └── api / hooks / components
└── routes/<route>/ ← TanStack Router 依存部分だけ
├── -components/ ← fallbacks / Page
└── index.tsx ← loader / validateSearchこんな感じにしました!
apps/<appname>/src/
├── features/
│ └── <domain>/
│ ├── api/ ← データ取得・更新 hook(queryOptions + useSuspenseQuery / useMutation)
│ ├── hooks/ ← form / フローなど画面ロジックの hook(Router 非依存)
│ ├── components/ ← View 断片(Router 非依存)
│ └── その他もろもろ
└── routes/
└── <route>/
├── -components/
│ ├── fallbacks/ ← 必要なルートのみ(pending / error の中身)
│ └── Page.tsx ← Page 本体(components/を使用して画面を構築、Router 依存を features に注入)
└── index.tsx ← validateSearch / loader / アダプタの宣言のみensureQueryData、component は useSuspenseQuery で読むpendingComponent、失敗すると errorComponent が出る
ensureQueryData で先に埋めておくので、Page 側は「取得済み前提」で書ける-components/fallbacks/ に中身を用意する// routes/contracts/index.tsx — 宣言だけ
export const Route = createFileRoute("/contracts")({
loader: ({ context }) => context.queryClient.ensureQueryData(contractsQueryOptions),
pendingComponent: ContractsPending, // -components/fallbacks/
errorComponent: ContractsError, // -components/fallbacks/
component: Page,
});
// -components/Page.tsx — データは取得済みとして読む
function Page() {
const { data } = useSuspenseQuery(contractsQueryOptions);
return <ContractList items={data} />;
}index は宣言だけ、Page は「取得済み前提」で useSuspenseQueryensureQueryData が routes と features の繋ぎ目しかし!
突如として
server 処理が必要になった
とはいえ何も分からん!!!
TSKaigi のこの 2 本が刺さった
→ この 2 つを参考に Server Function の置き場を決めた
createServerFn の中身は薄く保つ」 という点serverFn、どこに置く?
src/
├── features/<domain>/ ← api / hooks / components
├── routes/<route>/ ← ルーティング宣言のみ
└── server/<entity>/ ← ★ serverFn を全部ここへ集約
├── <entity>.functions.ts wrapper
├── <entity>-schema.ts schema
└── <verb>-<entity>.server.ts logicsrc/server に全部集約src/
├── features/<domain>/
│ ├── <entity>-schema.ts schema
│ └── <verb>-<entity>.server.ts logic(実体)
└── routes/<route>/
└── -api/
└── <entity>.functions.ts ★ wrapper だけ routes 側useServerFn で component(features 側)から呼べるsrc/features/<domain>/
└── server/ ← ★ wrapper も含めて全部 features
├── <entity>.functions.ts wrapper
├── <entity>-schema.ts schema
└── <verb>-<entity>.server.ts logicfeatures/<domain>/server に全部createServerFn の wrapper は Page のような薄い層ではなく、ドメインに関係していたそしてこんな形にした!
apps/<appname>/src/
├── features/
│ └── <domain>/
│ ├── api/ ← データ取得・更新 hook(queryOptions + useSuspenseQuery / useMutation)
│ ├── server/ ← ★ Server Function
│ │ ├── <entity>.functions.ts ← createServerFn のラッパー
│ │ ├── <entity>-schema.ts ← 入出力の schema
│ │ └── <verb>-<entity>.server.ts ← ロジック
│ ├── hooks/ ← form / フローなど画面ロジックの hook(Router 非依存)
│ ├── components/ ← View 断片(Router 非依存)
│ └── その他諸々
└── routes/
└── <route>/
├── -components/ ← fallbacks / Page.tsx
└── index.tsx ← validateSearch / loader / アダプタの宣言のみserver/(schema / function / logic)を追加しただけcreateServerFn は薄く保つ」という共通の指針が、構成を決める軸になったご清聴ありがとうございました