@kood/claude-code 0.3.8 → 0.3.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/templates/.claude/agents/code-reviewer.md +16 -1
- package/templates/.claude/agents/dependency-manager.md +16 -1
- package/templates/.claude/agents/deployment-validator.md +16 -1
- package/templates/.claude/agents/git-operator.md +16 -1
- package/templates/.claude/agents/implementation-executor.md +16 -1
- package/templates/.claude/agents/lint-fixer.md +16 -1
- package/templates/.claude/agents/refactor-advisor.md +16 -1
- package/templates/.claude/commands/agent-creator.md +16 -1
- package/templates/.claude/commands/bug-fix.md +16 -1
- package/templates/.claude/commands/command-creator.md +17 -1
- package/templates/.claude/commands/docs-creator.md +17 -1
- package/templates/.claude/commands/docs-refactor.md +17 -1
- package/templates/.claude/commands/execute.md +17 -1
- package/templates/.claude/commands/git-all.md +16 -1
- package/templates/.claude/commands/git-session.md +17 -1
- package/templates/.claude/commands/git.md +17 -1
- package/templates/.claude/commands/lint-fix.md +17 -1
- package/templates/.claude/commands/lint-init.md +17 -1
- package/templates/.claude/commands/plan.md +17 -1
- package/templates/.claude/commands/prd.md +17 -1
- package/templates/.claude/commands/pre-deploy.md +17 -1
- package/templates/.claude/commands/refactor.md +17 -1
- package/templates/.claude/commands/version-update.md +17 -1
- package/templates/hono/CLAUDE.md +1 -0
- package/templates/nextjs/CLAUDE.md +12 -9
- package/templates/nextjs/docs/architecture.md +812 -0
- package/templates/npx/CLAUDE.md +1 -0
- package/templates/tanstack-start/CLAUDE.md +1 -0
- package/templates/tanstack-start/docs/library/better-auth/index.md +225 -185
- package/templates/tanstack-start/docs/library/prisma/index.md +1025 -41
- package/templates/tanstack-start/docs/library/t3-env/index.md +207 -40
- package/templates/tanstack-start/docs/library/tanstack-query/index.md +878 -42
- package/templates/tanstack-start/docs/library/tanstack-router/index.md +602 -54
- package/templates/tanstack-start/docs/library/tanstack-start/index.md +1334 -33
- package/templates/tanstack-start/docs/library/zod/index.md +674 -31
|
@@ -1,22 +1,107 @@
|
|
|
1
1
|
# TanStack Router
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> v1 | Type-safe React Router
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<context>
|
|
8
|
+
|
|
9
|
+
**Purpose:** File-based routing with full TypeScript type safety for TanStack Start
|
|
10
|
+
|
|
11
|
+
**Version:** v1
|
|
12
|
+
|
|
13
|
+
**Key Features:**
|
|
14
|
+
- File-based routing (`__root.tsx`, `$param.tsx`, `_layout/`)
|
|
15
|
+
- Type-safe hooks (`Route.useLoaderData()`, `Route.useParams()`)
|
|
16
|
+
- Data loading (`loader`, `beforeLoad`)
|
|
17
|
+
- Search params validation (Zod integration)
|
|
18
|
+
- Route context for auth/state sharing
|
|
19
|
+
- Error boundaries and pending states
|
|
20
|
+
|
|
21
|
+
</context>
|
|
10
22
|
|
|
11
23
|
---
|
|
12
24
|
|
|
13
|
-
<
|
|
25
|
+
<forbidden>
|
|
26
|
+
|
|
27
|
+
| 분류 | 금지 행동 | 이유 |
|
|
28
|
+
|------|----------|------|
|
|
29
|
+
| **Hooks** | `useParams()`/`useSearch()` 타입 수동 지정 | `Route.useParams()`/`Route.useSearch()` 사용 (자동 타입) |
|
|
30
|
+
| **Search Params** | validateSearch 없이 search params 사용 | 타입 안전성 보장 불가 |
|
|
31
|
+
| **Navigation** | `window.location.href` 사용 | `<Link>` 또는 `useNavigate()` 사용 |
|
|
32
|
+
| **Context** | Context 없이 전역 상태 prop drilling | Route context 또는 Zustand 사용 |
|
|
33
|
+
| **Error** | try-catch로 에러 처리 | `errorComponent` 사용 |
|
|
34
|
+
|
|
35
|
+
</forbidden>
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
<required>
|
|
40
|
+
|
|
41
|
+
| 작업 | 필수 행동 |
|
|
42
|
+
|------|----------|
|
|
43
|
+
| **Search Params** | Zod 스키마로 `validateSearch` 정의 |
|
|
44
|
+
| **Type-safe Hooks** | `Route.useLoaderData()`, `Route.useParams()`, `Route.useSearch()` 사용 |
|
|
45
|
+
| **Protected Routes** | `_authed.tsx` + `beforeLoad`에서 인증 체크 |
|
|
46
|
+
| **Data Loading** | `loader`에서 데이터 fetch, TanStack Query와 통합 |
|
|
47
|
+
| **Error Handling** | `errorComponent`, `notFoundComponent` 정의 |
|
|
48
|
+
| **Navigation** | `<Link>`에 `params`, `search` 타입 안전하게 전달 |
|
|
49
|
+
|
|
50
|
+
</required>
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
<structure>
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
routes/
|
|
58
|
+
├── __root.tsx # Root layout (createRootRoute)
|
|
59
|
+
├── index.tsx # /
|
|
60
|
+
├── about.tsx # /about
|
|
61
|
+
├── posts/
|
|
62
|
+
│ ├── index.tsx # /posts
|
|
63
|
+
│ └── $postId.tsx # /posts/:postId
|
|
64
|
+
├── _authed/ # Protected (pathless)
|
|
65
|
+
│ ├── dashboard.tsx # /dashboard
|
|
66
|
+
│ └── settings.tsx # /settings
|
|
67
|
+
└── $.tsx # Catch-all (404)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
| 파일명 | 경로 | 설명 |
|
|
71
|
+
|--------|------|------|
|
|
72
|
+
| `__root.tsx` | - | Root layout (필수) |
|
|
73
|
+
| `index.tsx` | `/` | 디렉토리 루트 |
|
|
74
|
+
| `about.tsx` | `/about` | 정적 라우트 |
|
|
75
|
+
| `$postId.tsx` | `/posts/:postId` | 동적 세그먼트 |
|
|
76
|
+
| `_authed/dashboard.tsx` | `/dashboard` | Pathless layout (인증 등) |
|
|
77
|
+
| `$.tsx` | `/*` | Catch-all (404) |
|
|
78
|
+
|
|
79
|
+
</structure>
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
<route_options>
|
|
84
|
+
|
|
85
|
+
| 옵션 | 타입 | 설명 |
|
|
86
|
+
|------|------|------|
|
|
87
|
+
| `component` | Component | 렌더링할 컴포넌트 |
|
|
88
|
+
| `loader` | async function | 데이터 로드 (SSR/CSR 모두) |
|
|
89
|
+
| `beforeLoad` | async function | loader 전 실행 (인증, context 추가) |
|
|
90
|
+
| `validateSearch` | Zod schema | Search params 검증 + 타입 추론 |
|
|
91
|
+
| `loaderDeps` | function | search/params 변경 시 loader 재실행 |
|
|
92
|
+
| `errorComponent` | Component | Error throw 시 표시 |
|
|
93
|
+
| `notFoundComponent` | Component | notFound() throw 시 표시 |
|
|
94
|
+
| `pendingComponent` | Component | loader 실행 중 표시 |
|
|
95
|
+
| `pendingMs` | number | pendingComponent 표시 지연 (기본 1000ms) |
|
|
96
|
+
| `pendingMinMs` | number | pendingComponent 최소 표시 시간 (깜빡임 방지) |
|
|
14
97
|
|
|
15
98
|
```tsx
|
|
16
|
-
//
|
|
17
|
-
export const Route = createFileRoute('/about')({
|
|
99
|
+
// 기본 라우트
|
|
100
|
+
export const Route = createFileRoute('/about')({
|
|
101
|
+
component: AboutPage,
|
|
102
|
+
})
|
|
18
103
|
|
|
19
|
-
// Loader +
|
|
104
|
+
// Loader + 동적 파라미터
|
|
20
105
|
export const Route = createFileRoute('/posts/$postId')({
|
|
21
106
|
loader: async ({ params }) => ({ post: await getPost(params.postId) }),
|
|
22
107
|
component: PostPage,
|
|
@@ -50,66 +135,529 @@ const RootLayout = () => (
|
|
|
50
135
|
<main><Outlet /></main>
|
|
51
136
|
</div>
|
|
52
137
|
)
|
|
138
|
+
```
|
|
53
139
|
|
|
54
|
-
|
|
55
|
-
<Link to="/posts/$postId" params={{ postId: '123' }}>Post</Link>
|
|
56
|
-
<Link to="/products" search={{ page: 1 }}>Products</Link>
|
|
140
|
+
</route_options>
|
|
57
141
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
<navigation>
|
|
145
|
+
|
|
146
|
+
## Link Component
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
// 기본 사용법
|
|
150
|
+
<Link to="/about">About</Link>
|
|
151
|
+
<Link to="/posts/$postId" params={{ postId: '123' }}>Post 123</Link>
|
|
152
|
+
<Link to="/products" search={{ page: 1, sort: 'newest' }}>Products</Link>
|
|
153
|
+
<Link to="/products" search={prev => ({ ...prev, page: 2 })}>Next</Link>
|
|
154
|
+
|
|
155
|
+
// Active 스타일
|
|
156
|
+
<Link
|
|
157
|
+
to="/about"
|
|
158
|
+
activeProps={{ className: 'text-blue-500 font-bold' }}
|
|
159
|
+
inactiveProps={{ className: 'text-gray-500' }}
|
|
160
|
+
>
|
|
161
|
+
About
|
|
162
|
+
</Link>
|
|
163
|
+
<Link to="/" activeOptions={{ exact: true }}>Home</Link>
|
|
164
|
+
|
|
165
|
+
// Preloading
|
|
166
|
+
<Link to="/posts" preload="intent">Posts</Link> // hover 시
|
|
167
|
+
<Link to="/dashboard" preload="render">Dash</Link> // 렌더링 시
|
|
168
|
+
<Link to="/products" preload="viewport">Prod</Link> // viewport 진입 시
|
|
61
169
|
```
|
|
62
170
|
|
|
63
|
-
|
|
171
|
+
| Link Props | 타입 | 설명 |
|
|
172
|
+
|------------|------|------|
|
|
173
|
+
| `to` | string | 목적지 경로 |
|
|
174
|
+
| `params` | object | Path 파라미터 |
|
|
175
|
+
| `search` | object \| function | Search params (함수로 이전 값 접근) |
|
|
176
|
+
| `hash` | string | Hash |
|
|
177
|
+
| `replace` | boolean | history.replace 사용 |
|
|
178
|
+
| `preload` | 'intent' \| 'render' \| 'viewport' | Preload 전략 |
|
|
179
|
+
| `activeProps` | object | Active 시 props |
|
|
180
|
+
| `inactiveProps` | object | Inactive 시 props |
|
|
181
|
+
| `activeOptions` | object | Active 조건 (`exact` 등) |
|
|
64
182
|
|
|
65
|
-
|
|
183
|
+
## useNavigate
|
|
184
|
+
|
|
185
|
+
```tsx
|
|
186
|
+
const Component = () => {
|
|
187
|
+
const navigate = useNavigate()
|
|
188
|
+
|
|
189
|
+
const goToAbout = () => navigate({ to: '/about' })
|
|
190
|
+
const goToPost = (id: string) => navigate({ to: '/posts/$postId', params: { postId: id } })
|
|
191
|
+
const updateSearch = () => navigate({ to: '/products', search: prev => ({ ...prev, page: 2 }) })
|
|
192
|
+
const replaceRoute = () => navigate({ to: '/login', replace: true })
|
|
193
|
+
const goUp = () => navigate({ to: '..' })
|
|
194
|
+
|
|
195
|
+
return <button onClick={goToAbout}>Go</button>
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 조건부 네비게이션
|
|
199
|
+
const SubmitButton = () => {
|
|
200
|
+
const navigate = useNavigate()
|
|
201
|
+
const [isPending, startTransition] = useTransition()
|
|
202
|
+
|
|
203
|
+
const handleSubmit = async () => {
|
|
204
|
+
const result = await submitForm()
|
|
205
|
+
if (result.success) {
|
|
206
|
+
startTransition(() => navigate({ to: '/success' }))
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return <button onClick={handleSubmit} disabled={isPending}>Submit</button>
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
| navigate 옵션 | 타입 | 설명 |
|
|
215
|
+
|---------------|------|------|
|
|
216
|
+
| `to` | string | 목적지 경로 |
|
|
217
|
+
| `params` | object | Path 파라미터 |
|
|
218
|
+
| `search` | object \| function | Search params |
|
|
219
|
+
| `hash` | string | Hash |
|
|
220
|
+
| `replace` | boolean | history.replace 사용 |
|
|
221
|
+
| `resetScroll` | boolean | 스크롤 리셋 (기본 true) |
|
|
222
|
+
|
|
223
|
+
</navigation>
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
<search_params>
|
|
228
|
+
|
|
229
|
+
## Zod 스키마 + validateSearch
|
|
230
|
+
|
|
231
|
+
```tsx
|
|
232
|
+
// Zod 스키마 정의
|
|
233
|
+
const searchSchema = z.object({
|
|
234
|
+
page: z.number().catch(1), // 기본값
|
|
235
|
+
search: z.string().optional(), // 선택
|
|
236
|
+
sort: z.enum(['newest', 'price']).catch('newest'),
|
|
237
|
+
tags: z.array(z.string()).catch([]), // 배열
|
|
238
|
+
inStock: z.boolean().catch(true), // Boolean
|
|
239
|
+
from: z.string().date().optional(), // 날짜
|
|
240
|
+
minPrice: z.number().min(0).catch(0), // 범위
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
// 라우트에 적용
|
|
244
|
+
export const Route = createFileRoute('/products')({
|
|
245
|
+
validateSearch: searchSchema,
|
|
246
|
+
loaderDeps: ({ search }) => ({ search }), // search 변경 시 loader 재실행
|
|
247
|
+
loader: async ({ deps: { search } }) => fetchProducts(search),
|
|
248
|
+
component: ProductsPage,
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
const ProductsPage = () => {
|
|
252
|
+
const { page, search, sort } = Route.useSearch() // 타입 안전
|
|
253
|
+
return <div>Page: {page}, Sort: {sort}</div>
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## Search Params 업데이트
|
|
258
|
+
|
|
259
|
+
```tsx
|
|
260
|
+
// Link로 업데이트
|
|
261
|
+
<Link to="/products" search={{ page: 1, sort: 'newest' }}>Reset</Link>
|
|
262
|
+
<Link to="/products" search={prev => ({ ...prev, page: 2 })}>Next</Link>
|
|
263
|
+
|
|
264
|
+
// useNavigate로 업데이트
|
|
265
|
+
const Pagination = () => {
|
|
266
|
+
const navigate = useNavigate()
|
|
267
|
+
const { page } = Route.useSearch()
|
|
268
|
+
|
|
269
|
+
const goToPage = (newPage: number) => {
|
|
270
|
+
navigate({ to: '/products', search: prev => ({ ...prev, page: newPage }) })
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
<div>
|
|
275
|
+
<button onClick={() => goToPage(page - 1)} disabled={page <= 1}>Prev</button>
|
|
276
|
+
<span>Page {page}</span>
|
|
277
|
+
<button onClick={() => goToPage(page + 1)}>Next</button>
|
|
278
|
+
</div>
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## 실전: 필터 + 정렬 + 페이지네이션
|
|
284
|
+
|
|
285
|
+
```tsx
|
|
286
|
+
const PostsPage = () => {
|
|
287
|
+
const { page, search, category, sort } = Route.useSearch()
|
|
288
|
+
const posts = Route.useLoaderData()
|
|
289
|
+
const navigate = useNavigate()
|
|
290
|
+
|
|
291
|
+
const updateSearch = (updates: Partial<z.infer<typeof searchSchema>>) => {
|
|
292
|
+
navigate({ to: '/posts', search: prev => ({ ...prev, ...updates, page: 1 }) })
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<div>
|
|
297
|
+
<input value={search} onChange={e => updateSearch({ search: e.target.value })} />
|
|
298
|
+
<select value={category} onChange={e => updateSearch({ category: e.target.value as any })}>
|
|
299
|
+
<option value="all">All</option>
|
|
300
|
+
<option value="tech">Tech</option>
|
|
301
|
+
</select>
|
|
302
|
+
{posts.map(post => <div key={post.id}>{post.title}</div>)}
|
|
303
|
+
</div>
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
</search_params>
|
|
309
|
+
|
|
310
|
+
---
|
|
311
|
+
|
|
312
|
+
<route_context>
|
|
313
|
+
|
|
314
|
+
## beforeLoad + Context
|
|
315
|
+
|
|
316
|
+
```tsx
|
|
317
|
+
// beforeLoad: 인증 체크 + context 추가
|
|
318
|
+
export const Route = createFileRoute('/dashboard')({
|
|
319
|
+
beforeLoad: async ({ context, location }) => {
|
|
320
|
+
if (!context.auth.isAuthenticated) {
|
|
321
|
+
throw redirect({ to: '/login', search: { redirect: location.href } })
|
|
322
|
+
}
|
|
323
|
+
return { userPermissions: await fetchPermissions(context.auth.user.id) }
|
|
324
|
+
},
|
|
325
|
+
loader: async ({ context }) => fetchDashboardData(context.userPermissions),
|
|
326
|
+
component: DashboardPage,
|
|
327
|
+
})
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
## Protected Routes: _authed.tsx (pathless layout)
|
|
66
331
|
|
|
67
332
|
```
|
|
68
333
|
routes/
|
|
69
|
-
├──
|
|
70
|
-
├──
|
|
71
|
-
├──
|
|
72
|
-
├──
|
|
73
|
-
│
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
│ ├── dashboard.tsx # /dashboard
|
|
77
|
-
│ └── settings.tsx # /settings
|
|
78
|
-
└── $.tsx # Catch-all (404)
|
|
334
|
+
├── _authed.tsx # Protected layout
|
|
335
|
+
├── _authed/
|
|
336
|
+
│ ├── dashboard.tsx # /dashboard (protected)
|
|
337
|
+
│ ├── settings.tsx # /settings (protected)
|
|
338
|
+
│ └── profile.tsx # /profile (protected)
|
|
339
|
+
├── login.tsx # /login (public)
|
|
340
|
+
└── index.tsx # / (public)
|
|
79
341
|
```
|
|
80
342
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
343
|
+
```tsx
|
|
344
|
+
// _authed.tsx
|
|
345
|
+
export const Route = createFileRoute('/_authed')({
|
|
346
|
+
beforeLoad: async ({ location }) => {
|
|
347
|
+
const user = await getCurrentUser()
|
|
348
|
+
if (!user) throw redirect({ to: '/login', search: { redirect: location.href } })
|
|
349
|
+
return { user }
|
|
350
|
+
},
|
|
351
|
+
component: () => <Outlet />,
|
|
352
|
+
})
|
|
87
353
|
|
|
88
|
-
|
|
354
|
+
// _authed/dashboard.tsx
|
|
355
|
+
export const Route = createFileRoute('/_authed/dashboard')({
|
|
356
|
+
component: DashboardPage,
|
|
357
|
+
})
|
|
358
|
+
const DashboardPage = () => {
|
|
359
|
+
const { user } = Route.useRouteContext() // _authed에서 전달된 context
|
|
360
|
+
return <h1>Welcome, {user.name}!</h1>
|
|
361
|
+
}
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
## Root Context
|
|
365
|
+
|
|
366
|
+
```tsx
|
|
367
|
+
interface RouterContext {
|
|
368
|
+
queryClient: QueryClient
|
|
369
|
+
auth: { isAuthenticated: boolean; user: User | null }
|
|
370
|
+
}
|
|
89
371
|
|
|
90
|
-
<
|
|
372
|
+
export const Route = createRootRouteWithContext<RouterContext>()({
|
|
373
|
+
component: RootLayout,
|
|
374
|
+
})
|
|
91
375
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
| `validateSearch` | Search params schema |
|
|
98
|
-
| `pendingComponent` | Loading state component |
|
|
99
|
-
| `errorComponent` | Error component |
|
|
100
|
-
| `notFoundComponent` | Not found component |
|
|
376
|
+
const router = createRouter({
|
|
377
|
+
routeTree,
|
|
378
|
+
context: { queryClient, auth: { isAuthenticated: false, user: null } },
|
|
379
|
+
})
|
|
380
|
+
```
|
|
101
381
|
|
|
102
|
-
|
|
382
|
+
## redirect()
|
|
383
|
+
|
|
384
|
+
```tsx
|
|
385
|
+
throw redirect({ to: '/login' })
|
|
386
|
+
throw redirect({ to: '/login', search: { redirect: '/dashboard' } })
|
|
387
|
+
throw redirect({ to: '/posts/$postId', params: { postId: '123' } })
|
|
388
|
+
throw redirect({ to: '/home', replace: true })
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
## Context 접근
|
|
392
|
+
|
|
393
|
+
| 위치 | 접근 방법 |
|
|
394
|
+
|------|----------|
|
|
395
|
+
| `beforeLoad` | `{ context }` 파라미터 |
|
|
396
|
+
| `loader` | `{ context }` 파라미터 |
|
|
397
|
+
| `component` | `Route.useRouteContext()` |
|
|
398
|
+
|
|
399
|
+
</route_context>
|
|
400
|
+
|
|
401
|
+
---
|
|
103
402
|
|
|
104
403
|
<hooks>
|
|
105
404
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
405
|
+
## Route-Scoped Hooks (Type-safe, Recommended)
|
|
406
|
+
|
|
407
|
+
```tsx
|
|
408
|
+
const PostPage = () => {
|
|
409
|
+
const { post } = Route.useLoaderData() // Loader 반환값
|
|
410
|
+
const { postId } = Route.useParams() // Path params
|
|
411
|
+
const { page, sort } = Route.useSearch() // Search params
|
|
412
|
+
const { user } = Route.useRouteContext() // Route context
|
|
413
|
+
return <h1>{post.title}</h1>
|
|
414
|
+
}
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
## Global Hooks (Manual type)
|
|
418
|
+
|
|
419
|
+
```tsx
|
|
420
|
+
// useNavigate
|
|
421
|
+
const navigate = useNavigate()
|
|
422
|
+
navigate({ to: '/posts/$postId', params: { postId: '123' } })
|
|
423
|
+
|
|
424
|
+
// useMatch
|
|
425
|
+
const postMatch = useMatch({ from: '/posts/$postId', shouldThrow: false })
|
|
426
|
+
if (postMatch) return <span>Post: {postMatch.params.postId}</span>
|
|
427
|
+
|
|
428
|
+
// useParams (Global)
|
|
429
|
+
const { postId } = useParams({ from: '/posts/$postId' })
|
|
430
|
+
const params = useParams({ strict: false }) // 모든 params
|
|
431
|
+
|
|
432
|
+
// useSearch (Global)
|
|
433
|
+
const { page } = useSearch({ from: '/products' })
|
|
434
|
+
const search = useSearch({ strict: false }) // 현재 search
|
|
435
|
+
|
|
436
|
+
// useRouterState
|
|
437
|
+
const pathname = useRouterState({ select: state => state.location.pathname })
|
|
438
|
+
const isLoading = useRouterState({ select: state => state.isLoading })
|
|
439
|
+
|
|
440
|
+
// useLocation
|
|
441
|
+
const location = useLocation()
|
|
442
|
+
console.log(location.pathname) // '/posts/123'
|
|
443
|
+
console.log(location.search) // { page: 1 }
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
## Hooks Reference
|
|
447
|
+
|
|
448
|
+
| Hook | Scope | Type | 용도 |
|
|
449
|
+
|------|-------|------|------|
|
|
450
|
+
| `Route.useLoaderData()` | Route | Auto | Loader 데이터 |
|
|
451
|
+
| `Route.useParams()` | Route | Auto | Path params (타입 안전) |
|
|
452
|
+
| `Route.useSearch()` | Route | Auto | Search params (타입 안전) |
|
|
453
|
+
| `Route.useRouteContext()` | Route | Auto | Route context |
|
|
454
|
+
| `useParams({ from })` | Global | Manual | 다른 라우트 params |
|
|
455
|
+
| `useSearch({ from })` | Global | Manual | 다른 라우트 search |
|
|
456
|
+
| `useMatch({ from })` | Global | Manual | 라우트 매치 정보 |
|
|
457
|
+
| `useNavigate()` | Global | Auto | 네비게이션 |
|
|
458
|
+
| `useRouterState()` | Global | Manual | 라우터 상태 (pathname, isLoading) |
|
|
459
|
+
| `useLocation()` | Global | Auto | 현재 location (pathname, search, hash) |
|
|
114
460
|
|
|
115
461
|
</hooks>
|
|
462
|
+
|
|
463
|
+
---
|
|
464
|
+
|
|
465
|
+
<error_handling>
|
|
466
|
+
|
|
467
|
+
## errorComponent
|
|
468
|
+
|
|
469
|
+
```tsx
|
|
470
|
+
export const Route = createFileRoute('/posts/$postId')({
|
|
471
|
+
loader: async ({ params }) => {
|
|
472
|
+
const post = await getPost(params.postId)
|
|
473
|
+
if (!post) throw new Error('Post not found')
|
|
474
|
+
return { post }
|
|
475
|
+
},
|
|
476
|
+
errorComponent: PostError,
|
|
477
|
+
component: PostPage,
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
const PostError = ({ error, reset }: ErrorComponentProps) => (
|
|
481
|
+
<div>
|
|
482
|
+
<h2>Error loading post</h2>
|
|
483
|
+
<p>{error.message}</p>
|
|
484
|
+
<button onClick={reset}>Retry</button>
|
|
485
|
+
</div>
|
|
486
|
+
)
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
## notFoundComponent
|
|
490
|
+
|
|
491
|
+
```tsx
|
|
492
|
+
export const Route = createFileRoute('/posts/$postId')({
|
|
493
|
+
loader: async ({ params }) => {
|
|
494
|
+
const post = await getPost(params.postId)
|
|
495
|
+
if (!post) throw notFound({ data: { searchedId: params.postId } })
|
|
496
|
+
return { post }
|
|
497
|
+
},
|
|
498
|
+
notFoundComponent: ({ data }) => <p>Post {data?.searchedId} not found</p>,
|
|
499
|
+
component: PostPage,
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
// Root 404
|
|
503
|
+
export const Route = createRootRoute({
|
|
504
|
+
component: RootLayout,
|
|
505
|
+
notFoundComponent: () => (
|
|
506
|
+
<div>
|
|
507
|
+
<h1>404</h1>
|
|
508
|
+
<Link to="/">Go Home</Link>
|
|
509
|
+
</div>
|
|
510
|
+
),
|
|
511
|
+
})
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
## pendingComponent
|
|
515
|
+
|
|
516
|
+
```tsx
|
|
517
|
+
export const Route = createFileRoute('/posts')({
|
|
518
|
+
loader: async () => fetchPosts(),
|
|
519
|
+
pendingComponent: () => <Spinner />,
|
|
520
|
+
pendingMs: 200, // 200ms 후 표시
|
|
521
|
+
pendingMinMs: 500, // 최소 500ms 유지 (깜빡임 방지)
|
|
522
|
+
component: PostsPage,
|
|
523
|
+
})
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
## Catch-all (routes/$.tsx)
|
|
527
|
+
|
|
528
|
+
```tsx
|
|
529
|
+
export const Route = createFileRoute('/$')({
|
|
530
|
+
component: () => {
|
|
531
|
+
const { _splat } = Route.useParams()
|
|
532
|
+
return <div>Page Not Found: /{_splat}</div>
|
|
533
|
+
},
|
|
534
|
+
})
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
## 에러 타입 구분
|
|
538
|
+
|
|
539
|
+
```tsx
|
|
540
|
+
const CustomError = ({ error, reset }: ErrorComponentProps) => {
|
|
541
|
+
if (error instanceof TypeError && error.message.includes('fetch')) {
|
|
542
|
+
return <div><p>Network error</p><button onClick={reset}>Retry</button></div>
|
|
543
|
+
}
|
|
544
|
+
if (error.message.includes('unauthorized')) {
|
|
545
|
+
return <Navigate to="/login" />
|
|
546
|
+
}
|
|
547
|
+
return <div><p>Something went wrong</p><button onClick={reset}>Retry</button></div>
|
|
548
|
+
}
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
## 우선순위
|
|
552
|
+
|
|
553
|
+
| 우선순위 | 컴포넌트 | 조건 |
|
|
554
|
+
|---------|---------|------|
|
|
555
|
+
| 1 | `errorComponent` | loader/beforeLoad에서 Error throw |
|
|
556
|
+
| 2 | `notFoundComponent` | `notFound()` throw |
|
|
557
|
+
| 3 | `pendingComponent` | loader 실행 중 (pendingMs 이후) |
|
|
558
|
+
| 4 | `component` | 정상 렌더링 |
|
|
559
|
+
|
|
560
|
+
**에러 전파:** 하위 → 상위 (errorComponent 없으면 부모로 전파)
|
|
561
|
+
|
|
562
|
+
</error_handling>
|
|
563
|
+
|
|
564
|
+
---
|
|
565
|
+
|
|
566
|
+
<dos_donts>
|
|
567
|
+
|
|
568
|
+
## Do's & Don'ts
|
|
569
|
+
|
|
570
|
+
| ✅ Do | ❌ Don't |
|
|
571
|
+
|-------|---------|
|
|
572
|
+
| `Route.useParams()` (타입 안전) | `useParams()` (수동 타입) |
|
|
573
|
+
| `Route.useSearch()` (타입 안전) | `useSearch()` (수동 타입) |
|
|
574
|
+
| `validateSearch`로 Search params 검증 | Search params 검증 없이 사용 |
|
|
575
|
+
| `beforeLoad`에서 인증 체크 | `loader`에서 인증 체크 |
|
|
576
|
+
| `errorComponent`로 에러 처리 | try-catch로 에러 처리 |
|
|
577
|
+
| `<Link>` 또는 `useNavigate()` | `window.location.href` |
|
|
578
|
+
| `_authed/` layout으로 Protected Routes | 모든 라우트에 인증 로직 중복 |
|
|
579
|
+
| `loaderDeps`로 search 변경 감지 | useEffect로 수동 refetch |
|
|
580
|
+
| `pendingComponent`로 로딩 표시 | useQuery의 isLoading |
|
|
581
|
+
| `notFound()` throw | Error throw + 문자열 비교 |
|
|
582
|
+
|
|
583
|
+
</dos_donts>
|
|
584
|
+
|
|
585
|
+
---
|
|
586
|
+
|
|
587
|
+
<patterns>
|
|
588
|
+
|
|
589
|
+
## Quick Reference
|
|
590
|
+
|
|
591
|
+
```tsx
|
|
592
|
+
// ===== 기본 라우트 =====
|
|
593
|
+
export const Route = createFileRoute('/about')({ component: AboutPage })
|
|
594
|
+
|
|
595
|
+
// ===== Loader + 동적 파라미터 =====
|
|
596
|
+
export const Route = createFileRoute('/posts/$postId')({
|
|
597
|
+
loader: async ({ params }) => ({ post: await getPost(params.postId) }),
|
|
598
|
+
component: PostPage,
|
|
599
|
+
})
|
|
600
|
+
const PostPage = () => {
|
|
601
|
+
const { post } = Route.useLoaderData()
|
|
602
|
+
return <h1>{post.title}</h1>
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ===== Search Params (Zod) =====
|
|
606
|
+
export const Route = createFileRoute('/products')({
|
|
607
|
+
validateSearch: z.object({
|
|
608
|
+
page: z.number().catch(1),
|
|
609
|
+
sort: z.enum(['newest', 'price']).catch('newest'),
|
|
610
|
+
}),
|
|
611
|
+
component: ProductsPage,
|
|
612
|
+
})
|
|
613
|
+
const ProductsPage = () => {
|
|
614
|
+
const { page, sort } = Route.useSearch()
|
|
615
|
+
return <div>Page {page}, Sort: {sort}</div>
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ===== Root Route =====
|
|
619
|
+
export const Route = createRootRoute({
|
|
620
|
+
component: RootLayout,
|
|
621
|
+
notFoundComponent: () => <div>404</div>,
|
|
622
|
+
})
|
|
623
|
+
const RootLayout = () => (
|
|
624
|
+
<div>
|
|
625
|
+
<nav>{/* ... */}</nav>
|
|
626
|
+
<main><Outlet /></main>
|
|
627
|
+
</div>
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
// ===== Navigation =====
|
|
631
|
+
<Link to="/posts/$postId" params={{ postId: '123' }}>Post</Link>
|
|
632
|
+
<Link to="/products" search={{ page: 1 }}>Products</Link>
|
|
633
|
+
|
|
634
|
+
const navigate = useNavigate()
|
|
635
|
+
navigate({ to: '/posts/$postId', params: { postId: '123' } })
|
|
636
|
+
navigate({ to: '/products', search: prev => ({ ...prev, page: 2 }) })
|
|
637
|
+
|
|
638
|
+
// ===== Protected Routes =====
|
|
639
|
+
// _authed.tsx
|
|
640
|
+
export const Route = createFileRoute('/_authed')({
|
|
641
|
+
beforeLoad: async ({ location }) => {
|
|
642
|
+
const user = await getCurrentUser()
|
|
643
|
+
if (!user) throw redirect({ to: '/login', search: { redirect: location.href } })
|
|
644
|
+
return { user }
|
|
645
|
+
},
|
|
646
|
+
component: () => <Outlet />,
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
// ===== Error Handling =====
|
|
650
|
+
export const Route = createFileRoute('/posts/$postId')({
|
|
651
|
+
loader: async ({ params }) => {
|
|
652
|
+
const post = await getPost(params.postId)
|
|
653
|
+
if (!post) throw notFound()
|
|
654
|
+
return { post }
|
|
655
|
+
},
|
|
656
|
+
errorComponent: ({ error, reset }) => <div>{error.message}<button onClick={reset}>Retry</button></div>,
|
|
657
|
+
notFoundComponent: () => <div>Post not found</div>,
|
|
658
|
+
pendingComponent: () => <Spinner />,
|
|
659
|
+
component: PostPage,
|
|
660
|
+
})
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
</patterns>
|