@kood/claude-code 0.6.5 → 0.6.7
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 +255 -149
- package/package.json +1 -1
- package/templates/.claude/agents/researcher.md +8 -1
- package/templates/.claude/instructions/sourcing/reliable-search.md +49 -2
- package/templates/.claude/scripts/deploy/build-run.sh +36 -0
- package/templates/.claude/scripts/deploy/deploy-check.sh +38 -0
- package/templates/.claude/scripts/git/git-all.sh +57 -0
- package/templates/.claude/scripts/git/git-clean-check.sh +31 -0
- package/templates/.claude/scripts/git/git-commit.sh +51 -0
- package/templates/.claude/scripts/git/git-info.sh +34 -0
- package/templates/.claude/scripts/git/git-push.sh +50 -0
- package/templates/.claude/scripts/lint/lint-check.sh +56 -0
- package/templates/.claude/scripts/lint/lint-file.sh +41 -0
- package/templates/.claude/scripts/pm/pm-detect.sh +25 -0
- package/templates/.claude/scripts/pm/pm-run.sh +41 -0
- package/templates/.claude/scripts/version/version-bump.sh +54 -0
- package/templates/.claude/scripts/version/version-find.sh +49 -0
- package/templates/.claude/skills/docs-fetch/SKILL.md +5 -4
- package/templates/.claude/skills/project-optimizer/AGENTS.md +275 -0
- package/templates/.claude/skills/project-optimizer/SKILL.md +374 -0
- package/templates/.claude/skills/project-optimizer/rules/arch-config-centralize.md +66 -0
- package/templates/.claude/skills/project-optimizer/rules/arch-hot-path.md +35 -0
- package/templates/.claude/skills/project-optimizer/rules/arch-interface-segregation.md +51 -0
- package/templates/.claude/skills/project-optimizer/rules/arch-module-boundary.md +42 -0
- package/templates/.claude/skills/project-optimizer/rules/build-cache.md +57 -0
- package/templates/.claude/skills/project-optimizer/rules/build-code-split.md +56 -0
- package/templates/.claude/skills/project-optimizer/rules/build-incremental.md +65 -0
- package/templates/.claude/skills/project-optimizer/rules/build-minify.md +61 -0
- package/templates/.claude/skills/project-optimizer/rules/build-tree-shake.md +60 -0
- package/templates/.claude/skills/project-optimizer/rules/code-complexity.md +65 -0
- package/templates/.claude/skills/project-optimizer/rules/code-dead-elimination.md +32 -0
- package/templates/.claude/skills/project-optimizer/rules/code-duplication.md +54 -0
- package/templates/.claude/skills/project-optimizer/rules/code-error-handling.md +75 -0
- package/templates/.claude/skills/project-optimizer/rules/code-naming.md +52 -0
- package/templates/.claude/skills/project-optimizer/rules/concurrency-defer-await.md +54 -0
- package/templates/.claude/skills/project-optimizer/rules/concurrency-parallel.md +90 -0
- package/templates/.claude/skills/project-optimizer/rules/concurrency-pipeline.md +68 -0
- package/templates/.claude/skills/project-optimizer/rules/concurrency-pool.md +68 -0
- package/templates/.claude/skills/project-optimizer/rules/deps-lightweight-alt.md +37 -0
- package/templates/.claude/skills/project-optimizer/rules/deps-peer-align.md +44 -0
- package/templates/.claude/skills/project-optimizer/rules/deps-security-audit.md +45 -0
- package/templates/.claude/skills/project-optimizer/rules/deps-unused-removal.md +25 -0
- package/templates/.claude/skills/project-optimizer/rules/deps-version-pin.md +40 -0
- package/templates/.claude/skills/project-optimizer/rules/dx-ci-speed.md +47 -0
- package/templates/.claude/skills/project-optimizer/rules/dx-dev-server.md +35 -0
- package/templates/.claude/skills/project-optimizer/rules/dx-lint-config.md +36 -0
- package/templates/.claude/skills/project-optimizer/rules/dx-test-coverage.md +34 -0
- package/templates/.claude/skills/project-optimizer/rules/dx-type-safety.md +49 -0
- package/templates/.claude/skills/project-optimizer/rules/io-batch-queries.md +67 -0
- package/templates/.claude/skills/project-optimizer/rules/io-cache-layer.md +67 -0
- package/templates/.claude/skills/project-optimizer/rules/io-connection-reuse.md +67 -0
- package/templates/.claude/skills/project-optimizer/rules/io-serialize-minimal.md +61 -0
- package/templates/.claude/skills/project-optimizer/rules/io-stream.md +75 -0
- package/templates/.claude/skills/project-optimizer/rules/memory-bounded-cache.md +65 -0
- package/templates/.claude/skills/project-optimizer/rules/memory-large-data.md +64 -0
- package/templates/.claude/skills/project-optimizer/rules/memory-lazy-init.md +78 -0
- package/templates/.claude/skills/project-optimizer/rules/memory-leak-prevention.md +79 -0
- package/templates/.claude/skills/project-optimizer/rules/memory-pool-reuse.md +70 -0
- package/templates/.claude/skills/sql-optimizer/SKILL.md +437 -0
- package/templates/.claude/skills/sql-optimizer/orm-patterns.md +218 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/AGENTS.md +53 -14
- package/templates/.claude/skills/tanstack-start-react-best-practices/SKILL.md +93 -27
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/bundle-defer-third-party.md +42 -19
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/client-optimistic-updates.md +109 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/client-suspense-query.md +74 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/client-use-hook.md +81 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/rerender-react-compiler.md +81 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-beforeload-auth.md +121 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-file-conventions.md +104 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-link-navigation.md +119 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-nested-layouts.md +155 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-path-params.md +89 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-pending-component.md +110 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-preload-strategy.md +91 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-router-context.md +120 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-search-params.md +114 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/server-deferred-data.md +1 -1
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/server-error-boundaries.md +79 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/server-middleware.md +85 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/server-serialization.md +56 -21
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/server-streaming.md +84 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/server-validator.md +71 -0
- package/templates/.claude/skills/tauri-react-best-practices/AGENTS.md +527 -0
- package/templates/.claude/skills/tauri-react-best-practices/SKILL.md +570 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/bundle-barrel-imports.md +140 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/bundle-cargo-profile.md +96 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/bundle-frontend-treeshake.md +242 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/bundle-lazy-components.md +255 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/bundle-remove-unused-commands.md +160 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/deploy-ci-pipeline.md +269 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/deploy-signing.md +207 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/deploy-updater.md +226 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/ipc-async-commands.md +172 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/ipc-batch-commands.md +133 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/ipc-binary-response.md +198 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/ipc-channel-streaming.md +186 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/ipc-error-handling.md +250 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/ipc-type-safe.md +227 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/perf-derived-state.md +231 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/perf-functional-setstate.md +191 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/perf-index-maps.md +276 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/perf-lazy-state-init.md +196 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/plugin-lifecycle.md +265 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/plugin-mobile-compat.md +199 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/plugin-permission-scope.md +193 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/react-error-boundary.md +239 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/react-event-listener.md +151 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/react-file-src.md +155 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/react-invoke-hook.md +139 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/react-optimistic-update.md +211 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/security-capability-split.md +205 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/security-csp.md +207 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/security-least-privilege.md +106 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/security-no-wildcard.md +253 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/security-scope-paths.md +160 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/state-async-mutex.md +270 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/state-mutex-pattern.md +265 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/state-react-sync.md +375 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/state-single-container.md +275 -0
- package/templates/tanstack-start/docs/architecture.md +238 -167
- package/templates/tanstack-start/docs/library/tanstack-router/error-handling.md +777 -38
- package/templates/tanstack-start/docs/library/tanstack-router/hooks.md +549 -37
- package/templates/tanstack-start/docs/library/tanstack-router/index.md +895 -111
- package/templates/tanstack-start/docs/library/tanstack-router/navigation.md +641 -43
- package/templates/tanstack-start/docs/library/tanstack-router/route-context.md +889 -38
- package/templates/tanstack-start/docs/library/tanstack-router/search-params.md +891 -29
- package/templates/tanstack-start/docs/library/tanstack-start/auth-patterns.md +972 -36
- package/templates/tanstack-start/docs/library/tanstack-start/index.md +1525 -881
- package/templates/tanstack-start/docs/library/tanstack-start/middleware.md +1099 -20
- package/templates/tanstack-start/docs/library/tanstack-start/routing.md +796 -30
- package/templates/tanstack-start/docs/library/tanstack-start/server-functions.md +953 -35
- package/templates/tanstack-start/docs/library/tanstack-start/setup.md +371 -15
- package/templates/tauri/CLAUDE.md +189 -0
- package/templates/tauri/docs/guides/distribution.md +261 -0
- package/templates/tauri/docs/guides/getting-started.md +302 -0
- package/templates/tauri/docs/guides/mobile.md +288 -0
- package/templates/tauri/docs/library/tauri/index.md +510 -0
|
@@ -1,74 +1,936 @@
|
|
|
1
1
|
# TanStack Router - Search Params
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> TanStack Router v1.159.4
|
|
4
|
+
|
|
5
|
+
Search params 검증, 읽기, 쓰기, 직렬화를 다룬다.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<why_not_url_search_params>
|
|
10
|
+
|
|
11
|
+
## 왜 URLSearchParams를 직접 쓰지 않는가
|
|
12
|
+
|
|
13
|
+
TanStack Router는 JSON-first Search Params를 사용한다. URLSearchParams의 한계:
|
|
14
|
+
|
|
15
|
+
- 항상 문자열만 지원
|
|
16
|
+
- 대부분 flat 구조
|
|
17
|
+
- 중첩 객체/배열 지원 미흡
|
|
18
|
+
- 매번 파싱 시 참조 무결성 상실 (React 성능 이슈)
|
|
19
|
+
|
|
20
|
+
TanStack Router의 장점:
|
|
21
|
+
|
|
22
|
+
- 숫자, boolean 등 원시 타입 보존
|
|
23
|
+
- 중첩 객체/배열을 JSON으로 자동 직렬화
|
|
24
|
+
- 첫 번째 레벨은 URLSearchParams 호환 유지
|
|
25
|
+
- Structural Sharing으로 불필요한 리렌더 방지
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
// 이 네비게이션은:
|
|
29
|
+
<Link
|
|
30
|
+
to="/shop"
|
|
31
|
+
search={{
|
|
32
|
+
pageIndex: 3,
|
|
33
|
+
includeCategories: ['electronics', 'gifts'],
|
|
34
|
+
sortBy: 'price',
|
|
35
|
+
desc: true,
|
|
36
|
+
}}
|
|
37
|
+
/>
|
|
38
|
+
|
|
39
|
+
// 이 URL을 생성:
|
|
40
|
+
// /shop?pageIndex=3&includeCategories=%5B%22electronics%22%2C%22gifts%22%5D&sortBy=price&desc=true
|
|
41
|
+
|
|
42
|
+
// 파싱하면 정확히 원래 JSON으로 복원:
|
|
43
|
+
// { pageIndex: 3, includeCategories: ["electronics", "gifts"], sortBy: "price", desc: true }
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
</why_not_url_search_params>
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
<zod_basic>
|
|
51
|
+
|
|
52
|
+
## Zod 스키마: 기본 접근
|
|
53
|
+
|
|
54
|
+
validateSearch로 search params를 Zod 스키마로 검증. 타입 안전과 기본값 제공.
|
|
55
|
+
|
|
56
|
+
### 스키마 정의
|
|
4
57
|
|
|
5
58
|
```tsx
|
|
6
|
-
|
|
59
|
+
import { z } from 'zod'
|
|
60
|
+
|
|
7
61
|
const searchSchema = z.object({
|
|
8
|
-
|
|
9
|
-
|
|
62
|
+
// 기본값 (catch) - 유효하지 않은 값이면 기본값 사용
|
|
63
|
+
page: z.number().catch(1),
|
|
10
64
|
sort: z.enum(['newest', 'price']).catch('newest'),
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
65
|
+
|
|
66
|
+
// 선택 필드
|
|
67
|
+
search: z.string().optional(),
|
|
68
|
+
category: z.string().optional(),
|
|
69
|
+
|
|
70
|
+
// 배열
|
|
71
|
+
tags: z.array(z.string()).catch([]),
|
|
72
|
+
|
|
73
|
+
// Boolean
|
|
74
|
+
inStock: z.boolean().catch(true),
|
|
75
|
+
|
|
76
|
+
// 날짜 (ISO 형식)
|
|
77
|
+
from: z.string().date().optional(),
|
|
78
|
+
to: z.string().date().optional(),
|
|
79
|
+
|
|
80
|
+
// 범위
|
|
81
|
+
minPrice: z.number().min(0).catch(0),
|
|
82
|
+
maxPrice: z.number().max(10000).optional(),
|
|
83
|
+
})
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### 라우트에 적용
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
export const Route = createFileRoute('/products')({
|
|
90
|
+
validateSearch: searchSchema,
|
|
91
|
+
component: ProductsPage,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const ProductsPage = () => {
|
|
95
|
+
// 타입 안전: page, sort 타입 추론됨
|
|
96
|
+
const { page, sort, search } = Route.useSearch()
|
|
97
|
+
|
|
98
|
+
return <div>Page {page}, Sort: {sort}</div>
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### catch() vs default() 선택
|
|
103
|
+
|
|
104
|
+
```tsx
|
|
105
|
+
// catch(): 검증 실패 시 기본값 (에러 안 남, 권장)
|
|
106
|
+
page: z.number().catch(1)
|
|
107
|
+
|
|
108
|
+
// default(): 값이 undefined일 때 기본값 (검증 실패 시 에러)
|
|
109
|
+
page: z.number().default(1)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
> `catch()`를 권장하는 이유: search params가 잘못되었을 때 사용자 경험을 중단시키지 않기 위함. 에러를 보여줘야 한다면 `default()` 사용.
|
|
113
|
+
|
|
114
|
+
### 검증 실패 시 에러 처리
|
|
115
|
+
|
|
116
|
+
`validateSearch`에서 에러가 throw되면 `error.routerCode`가 `VALIDATE_SEARCH`로 설정되고 `errorComponent`가 렌더링됨.
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
export const Route = createFileRoute('/products')({
|
|
120
|
+
validateSearch: z.object({
|
|
121
|
+
page: z.number().min(1), // catch 없으면 실패 시 에러
|
|
122
|
+
}),
|
|
123
|
+
errorComponent: ({ error }) => {
|
|
124
|
+
if (error.routerCode === 'VALIDATE_SEARCH') {
|
|
125
|
+
return <div>잘못된 검색 파라미터입니다.</div>
|
|
126
|
+
}
|
|
127
|
+
return <div>{error.message}</div>
|
|
128
|
+
},
|
|
129
|
+
component: ProductsPage,
|
|
130
|
+
})
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
</zod_basic>
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
<zod_adapter>
|
|
138
|
+
|
|
139
|
+
## Zod Adapter: 고급 검증
|
|
140
|
+
|
|
141
|
+
`@tanstack/zod-adapter`의 `zodValidator()`를 사용하면 `input`/`output` 타입이 정확히 추론됨.
|
|
142
|
+
|
|
143
|
+
### 설치
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
npm install @tanstack/zod-adapter zod
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### 기본 사용 (default와 함께)
|
|
150
|
+
|
|
151
|
+
```tsx
|
|
152
|
+
import { zodValidator } from '@tanstack/zod-adapter'
|
|
153
|
+
import { z } from 'zod'
|
|
154
|
+
|
|
155
|
+
const searchSchema = z.object({
|
|
156
|
+
page: z.number().default(1),
|
|
157
|
+
filter: z.string().default(''),
|
|
158
|
+
sort: z.enum(['newest', 'oldest', 'price']).default('newest'),
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
export const Route = createFileRoute('/products')({
|
|
162
|
+
validateSearch: zodValidator(searchSchema),
|
|
163
|
+
component: ProductsPage,
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
// Link에서 search가 선택적으로 됨 (default 덕분)
|
|
167
|
+
<Link to="/products" /> // search 없어도 됨
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### fallback(): 타입 유지 + 검증 실패 기본값
|
|
171
|
+
|
|
172
|
+
`catch()`는 타입을 `unknown`으로 만들 수 있어, adapter의 `fallback()`으로 타입 유지.
|
|
173
|
+
|
|
174
|
+
```tsx
|
|
175
|
+
import { fallback, zodValidator } from '@tanstack/zod-adapter'
|
|
176
|
+
import { z } from 'zod'
|
|
177
|
+
|
|
178
|
+
const searchSchema = z.object({
|
|
179
|
+
page: fallback(z.number(), 1).default(1),
|
|
180
|
+
filter: fallback(z.string(), '').default(''),
|
|
181
|
+
sort: fallback(z.enum(['newest', 'oldest', 'price']), 'newest').default('newest'),
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
export const Route = createFileRoute('/products')({
|
|
185
|
+
validateSearch: zodValidator(searchSchema),
|
|
186
|
+
component: ProductsPage,
|
|
187
|
+
})
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### input/output 타입 커스텀
|
|
191
|
+
|
|
192
|
+
```tsx
|
|
193
|
+
// 네비게이션 시 input 타입과 읽기 시 output 타입을 다르게 설정
|
|
194
|
+
export const Route = createFileRoute('/products')({
|
|
195
|
+
validateSearch: zodValidator({
|
|
196
|
+
schema: searchSchema,
|
|
197
|
+
input: 'output', // 네비게이션 시 output 타입 사용
|
|
198
|
+
output: 'input', // 읽기 시 input 타입 사용
|
|
199
|
+
}),
|
|
200
|
+
})
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### 에러 처리
|
|
204
|
+
|
|
205
|
+
```tsx
|
|
206
|
+
import { SearchParamError } from '@tanstack/router'
|
|
207
|
+
|
|
208
|
+
export const Route = createFileRoute('/products')({
|
|
209
|
+
validateSearch: zodValidator(searchSchema),
|
|
210
|
+
// 검증 실패 시 처리
|
|
211
|
+
errorComponent: ({ error }) => (
|
|
212
|
+
<div>
|
|
213
|
+
<p>Invalid search params</p>
|
|
214
|
+
<pre>{error.message}</pre>
|
|
215
|
+
</div>
|
|
216
|
+
),
|
|
217
|
+
component: ProductsPage,
|
|
218
|
+
})
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
</zod_adapter>
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
<valibot_support>
|
|
226
|
+
|
|
227
|
+
## Valibot 지원 (v1.0+)
|
|
228
|
+
|
|
229
|
+
Valibot은 Standard Schema를 native로 지원. adapter 없이 바로 사용 가능.
|
|
230
|
+
|
|
231
|
+
### 설치
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
npm install valibot
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### 기본 사용
|
|
238
|
+
|
|
239
|
+
```tsx
|
|
240
|
+
import * as v from 'valibot'
|
|
241
|
+
|
|
242
|
+
const searchSchema = v.object({
|
|
243
|
+
page: v.optional(v.fallback(v.number(), 1), 1),
|
|
244
|
+
filter: v.optional(v.fallback(v.string(), ''), ''),
|
|
245
|
+
sort: v.optional(
|
|
246
|
+
v.fallback(v.picklist(['newest', 'oldest', 'price']), 'newest'),
|
|
247
|
+
'newest',
|
|
248
|
+
),
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
export const Route = createFileRoute('/products')({
|
|
252
|
+
validateSearch: searchSchema, // Standard Schema 자동 인식
|
|
253
|
+
component: ProductsPage,
|
|
254
|
+
})
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
</valibot_support>
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
<arktype_support>
|
|
262
|
+
|
|
263
|
+
## ArkType 지원 (2.0-rc+)
|
|
264
|
+
|
|
265
|
+
ArkType도 Standard Schema를 구현. adapter 없이 사용 가능.
|
|
266
|
+
|
|
267
|
+
```tsx
|
|
268
|
+
import { type } from 'arktype'
|
|
269
|
+
|
|
270
|
+
const searchSchema = type({
|
|
271
|
+
page: 'number = 1',
|
|
272
|
+
filter: 'string = ""',
|
|
273
|
+
sort: '"newest" | "oldest" | "price" = "newest"',
|
|
15
274
|
})
|
|
16
275
|
|
|
17
276
|
export const Route = createFileRoute('/products')({
|
|
18
277
|
validateSearch: searchSchema,
|
|
19
|
-
|
|
20
|
-
|
|
278
|
+
component: ProductsPage,
|
|
279
|
+
})
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
</arktype_support>
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
<loader_deps>
|
|
287
|
+
|
|
288
|
+
## loaderDeps: Search 의존 Loader
|
|
289
|
+
|
|
290
|
+
search params가 변경될 때 loader를 자동으로 재실행.
|
|
291
|
+
|
|
292
|
+
중요: **사용하는 deps만 포함해야 한다.** 불필요한 deps 추가 시 불필요한 재실행 발생.
|
|
293
|
+
|
|
294
|
+
### 기본 예시
|
|
295
|
+
|
|
296
|
+
```tsx
|
|
297
|
+
const postsSchema = z.object({
|
|
298
|
+
page: z.number().catch(1),
|
|
299
|
+
search: z.string().optional(),
|
|
300
|
+
category: z.string().optional(),
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
export const Route = createFileRoute('/posts')({
|
|
304
|
+
validateSearch: postsSchema,
|
|
305
|
+
// loaderDeps: search 변경 시 loader 재실행
|
|
306
|
+
loaderDeps: ({ search }) => ({ search }),
|
|
307
|
+
loader: async ({ deps: { search } }) => {
|
|
308
|
+
// search 변경될 때마다 재실행
|
|
309
|
+
return fetchPosts({
|
|
310
|
+
page: search.page,
|
|
311
|
+
query: search.search,
|
|
312
|
+
category: search.category,
|
|
313
|
+
})
|
|
314
|
+
},
|
|
315
|
+
component: PostsPage,
|
|
316
|
+
})
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### params와 함께 사용
|
|
320
|
+
|
|
321
|
+
```tsx
|
|
322
|
+
export const Route = createFileRoute('/users/$userId/posts')({
|
|
323
|
+
validateSearch: z.object({
|
|
324
|
+
page: z.number().catch(1),
|
|
325
|
+
}),
|
|
326
|
+
// 사용하는 deps만 포함
|
|
327
|
+
loaderDeps: ({ search, params }) => ({
|
|
328
|
+
page: search.page,
|
|
329
|
+
userId: params.userId,
|
|
330
|
+
}),
|
|
331
|
+
loader: async ({ deps: { page, userId } }) => {
|
|
332
|
+
return fetchUserPosts(userId, page)
|
|
333
|
+
},
|
|
334
|
+
component: UserPostsPage,
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
const UserPostsPage = () => {
|
|
338
|
+
const posts = Route.useLoaderData()
|
|
339
|
+
const { page } = Route.useSearch()
|
|
340
|
+
const { userId } = Route.useParams()
|
|
341
|
+
|
|
342
|
+
return <div>{posts.length} posts for user {userId}</div>
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### 주의: 불필요한 deps 피하기
|
|
347
|
+
|
|
348
|
+
```tsx
|
|
349
|
+
// 잘못된 예: 전체 search 반환 (안 쓰는 필드 변경에도 리로드)
|
|
350
|
+
loaderDeps: ({ search }) => search,
|
|
351
|
+
loader: ({ deps }) => fetchPosts({ page: deps.page }), // page만 쓰는데!
|
|
352
|
+
|
|
353
|
+
// 올바른 예: 사용하는 것만 포함
|
|
354
|
+
loaderDeps: ({ search }) => ({
|
|
355
|
+
page: search.page,
|
|
356
|
+
limit: search.limit,
|
|
357
|
+
}),
|
|
358
|
+
loader: ({ deps }) => fetchPosts(deps),
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
</loader_deps>
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
<reading>
|
|
366
|
+
|
|
367
|
+
## Search Params 읽기
|
|
368
|
+
|
|
369
|
+
### 컴포넌트에서 읽기 (Route.useSearch)
|
|
370
|
+
|
|
371
|
+
라우트 내부에서 타입 안전하게 접근.
|
|
372
|
+
|
|
373
|
+
```tsx
|
|
374
|
+
export const Route = createFileRoute('/products')({
|
|
375
|
+
validateSearch: z.object({
|
|
376
|
+
page: z.number().catch(1),
|
|
377
|
+
sort: z.enum(['newest', 'price']).catch('newest'),
|
|
378
|
+
}),
|
|
21
379
|
component: ProductsPage,
|
|
22
380
|
})
|
|
23
381
|
|
|
24
382
|
const ProductsPage = () => {
|
|
25
|
-
|
|
26
|
-
|
|
383
|
+
// 타입 안전: page, sort 타입 자동 추론
|
|
384
|
+
const { page, sort } = Route.useSearch()
|
|
385
|
+
|
|
386
|
+
return (
|
|
387
|
+
<div>
|
|
388
|
+
<h1>Products</h1>
|
|
389
|
+
<p>Page {page}, Sorted by {sort}</p>
|
|
390
|
+
</div>
|
|
391
|
+
)
|
|
27
392
|
}
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
### 부모 라우트의 Search Params 상속
|
|
396
|
+
|
|
397
|
+
부모 라우트의 search params 타입이 자식 라우트에서도 접근 가능.
|
|
398
|
+
|
|
399
|
+
```tsx
|
|
400
|
+
// /routes/shop/products.tsx
|
|
401
|
+
export const Route = createFileRoute('/shop/products')({
|
|
402
|
+
validateSearch: z.object({
|
|
403
|
+
page: z.number().catch(1),
|
|
404
|
+
sort: z.enum(['newest', 'price']).catch('newest'),
|
|
405
|
+
}),
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
// /routes/shop/products/$productId.tsx
|
|
409
|
+
export const Route = createFileRoute('/shop/products/$productId')({
|
|
410
|
+
beforeLoad: ({ search }) => {
|
|
411
|
+
search // ProductSearch 타입 상속됨
|
|
412
|
+
},
|
|
413
|
+
})
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
### Loader에서 읽기 (loaderDeps)
|
|
417
|
+
|
|
418
|
+
```tsx
|
|
419
|
+
export const Route = createFileRoute('/products')({
|
|
420
|
+
validateSearch: z.object({
|
|
421
|
+
page: z.number().catch(1),
|
|
422
|
+
category: z.string().optional(),
|
|
423
|
+
}),
|
|
424
|
+
loaderDeps: ({ search }) => ({ search }),
|
|
425
|
+
loader: async ({ deps: { search } }) => {
|
|
426
|
+
// Loader에서 search 접근
|
|
427
|
+
const products = await fetchProducts({
|
|
428
|
+
page: search.page,
|
|
429
|
+
category: search.category,
|
|
430
|
+
})
|
|
431
|
+
return { products }
|
|
432
|
+
},
|
|
433
|
+
component: ProductsPage,
|
|
434
|
+
})
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
### 라우트 외부에서 읽기 (useSearch)
|
|
28
438
|
|
|
29
|
-
|
|
30
|
-
<Link to="/products" search={{ page: 1, sort: 'newest' }}>Reset</Link>
|
|
31
|
-
<Link to="/products" search={prev => ({ ...prev, page: 2 })}>Next</Link>
|
|
439
|
+
헤더, 사이드바 등 다른 라우트의 search params 접근.
|
|
32
440
|
|
|
33
|
-
|
|
441
|
+
```tsx
|
|
442
|
+
// `/products` 라우트의 search params를 다른 곳에서 읽기
|
|
443
|
+
const Header = () => {
|
|
444
|
+
// from 명시로 타입 안전
|
|
445
|
+
const { search } = useSearch({ from: '/products' })
|
|
446
|
+
|
|
447
|
+
return <div>Search: {search}</div>
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// 현재 라우트의 search params (무조건적)
|
|
451
|
+
const Sidebar = () => {
|
|
452
|
+
const search = useSearch({ strict: false }) // 모든 search 접근 가능
|
|
453
|
+
return <div>{JSON.stringify(search)}</div>
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// getRouteApi로 파일 분리 시 타입 안전
|
|
457
|
+
import { getRouteApi } from '@tanstack/react-router'
|
|
458
|
+
const routeApi = getRouteApi('/products')
|
|
459
|
+
|
|
460
|
+
const Filter = () => {
|
|
461
|
+
const { page, sort } = routeApi.useSearch()
|
|
462
|
+
return <div>Page {page}</div>
|
|
463
|
+
}
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
</reading>
|
|
467
|
+
|
|
468
|
+
---
|
|
469
|
+
|
|
470
|
+
<writing>
|
|
471
|
+
|
|
472
|
+
## Search Params 쓰기
|
|
473
|
+
|
|
474
|
+
### Link로 업데이트
|
|
475
|
+
|
|
476
|
+
```tsx
|
|
477
|
+
// 절대값 설정
|
|
478
|
+
<Link to="/products" search={{ page: 1, sort: 'newest' }}>
|
|
479
|
+
Reset
|
|
480
|
+
</Link>
|
|
481
|
+
|
|
482
|
+
// 함수로 이전 값 기반 업데이트
|
|
483
|
+
<Link to="/products" search={prev => ({ ...prev, page: 2 })}>
|
|
484
|
+
Next Page
|
|
485
|
+
</Link>
|
|
486
|
+
|
|
487
|
+
// from 지정으로 to 생략 (현재 페이지 search 업데이트)
|
|
488
|
+
<Link from={Route.fullPath} search={prev => ({ page: prev.page + 1 })}>
|
|
489
|
+
Next Page
|
|
490
|
+
</Link>
|
|
491
|
+
|
|
492
|
+
// to="."으로 느슨한 타입의 search 업데이트 (범용 컴포넌트)
|
|
493
|
+
<Link to="." search={prev => ({ ...prev, page: prev.page + 1 })}>
|
|
494
|
+
Next Page
|
|
495
|
+
</Link>
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
### useNavigate로 업데이트
|
|
499
|
+
|
|
500
|
+
```tsx
|
|
34
501
|
const Pagination = () => {
|
|
35
502
|
const navigate = useNavigate()
|
|
36
503
|
const { page } = Route.useSearch()
|
|
37
504
|
|
|
38
505
|
const goToPage = (newPage: number) => {
|
|
39
|
-
navigate({
|
|
506
|
+
navigate({
|
|
507
|
+
to: '/products',
|
|
508
|
+
search: prev => ({ ...prev, page: newPage }),
|
|
509
|
+
})
|
|
40
510
|
}
|
|
41
511
|
|
|
42
512
|
return (
|
|
43
513
|
<div>
|
|
44
|
-
<button onClick={() => goToPage(page - 1)} disabled={page <= 1}>
|
|
514
|
+
<button onClick={() => goToPage(page - 1)} disabled={page <= 1}>
|
|
515
|
+
Previous
|
|
516
|
+
</button>
|
|
45
517
|
<span>Page {page}</span>
|
|
46
|
-
<button onClick={() => goToPage(page + 1)}>
|
|
518
|
+
<button onClick={() => goToPage(page + 1)}>
|
|
519
|
+
Next
|
|
520
|
+
</button>
|
|
47
521
|
</div>
|
|
48
522
|
)
|
|
49
523
|
}
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
### router.navigate로 직접 업데이트
|
|
527
|
+
|
|
528
|
+
```tsx
|
|
529
|
+
const Component = () => {
|
|
530
|
+
const router = useRouter()
|
|
531
|
+
|
|
532
|
+
const clearFilters = () => {
|
|
533
|
+
router.navigate({
|
|
534
|
+
to: '/products',
|
|
535
|
+
search: { page: 1, sort: 'newest' },
|
|
536
|
+
})
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return <button onClick={clearFilters}>Clear Filters</button>
|
|
540
|
+
}
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
</writing>
|
|
544
|
+
|
|
545
|
+
---
|
|
546
|
+
|
|
547
|
+
<search_middleware>
|
|
548
|
+
|
|
549
|
+
## Search Middleware: 자동 변환
|
|
550
|
+
|
|
551
|
+
Search params를 링크 생성 시 및 네비게이션 후 자동 변환.
|
|
552
|
+
|
|
553
|
+
### retainSearchParams: search 유지
|
|
554
|
+
|
|
555
|
+
특정 search params를 모든 하위 링크에서 자동 유지.
|
|
556
|
+
|
|
557
|
+
```tsx
|
|
558
|
+
import { retainSearchParams } from '@tanstack/react-router'
|
|
559
|
+
import { zodValidator } from '@tanstack/zod-adapter'
|
|
560
|
+
import { z } from 'zod'
|
|
561
|
+
|
|
562
|
+
const searchSchema = z.object({
|
|
563
|
+
rootValue: z.string().optional(),
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
export const Route = createRootRoute({
|
|
567
|
+
validateSearch: zodValidator(searchSchema),
|
|
568
|
+
search: {
|
|
569
|
+
middlewares: [retainSearchParams(['rootValue'])],
|
|
570
|
+
},
|
|
571
|
+
})
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
### stripSearchParams: 기본값 제거
|
|
575
|
+
|
|
576
|
+
기본값과 동일한 search params를 URL에서 자동 제거 (깔끔한 URL).
|
|
577
|
+
|
|
578
|
+
```tsx
|
|
579
|
+
import { stripSearchParams } from '@tanstack/react-router'
|
|
580
|
+
import { zodValidator } from '@tanstack/zod-adapter'
|
|
581
|
+
import { z } from 'zod'
|
|
582
|
+
|
|
583
|
+
const defaultValues = {
|
|
584
|
+
one: 'abc',
|
|
585
|
+
two: 'xyz',
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const searchSchema = z.object({
|
|
589
|
+
one: z.string().default(defaultValues.one),
|
|
590
|
+
two: z.string().default(defaultValues.two),
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
export const Route = createFileRoute('/hello')({
|
|
594
|
+
validateSearch: zodValidator(searchSchema),
|
|
595
|
+
search: {
|
|
596
|
+
middlewares: [stripSearchParams(defaultValues)],
|
|
597
|
+
},
|
|
598
|
+
})
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
### 여러 미들웨어 체이닝
|
|
602
|
+
|
|
603
|
+
```tsx
|
|
604
|
+
export const Route = createFileRoute('/search')({
|
|
605
|
+
validateSearch: zodValidator(
|
|
606
|
+
z.object({
|
|
607
|
+
retainMe: z.string().optional(),
|
|
608
|
+
arrayWithDefaults: z.string().array().default(['foo', 'bar']),
|
|
609
|
+
required: z.string(),
|
|
610
|
+
}),
|
|
611
|
+
),
|
|
612
|
+
search: {
|
|
613
|
+
middlewares: [
|
|
614
|
+
retainSearchParams(['retainMe']),
|
|
615
|
+
stripSearchParams({ arrayWithDefaults: ['foo', 'bar'] }),
|
|
616
|
+
],
|
|
617
|
+
},
|
|
618
|
+
})
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
### 커스텀 미들웨어
|
|
622
|
+
|
|
623
|
+
```tsx
|
|
624
|
+
export const Route = createRootRoute({
|
|
625
|
+
validateSearch: zodValidator(searchSchema),
|
|
626
|
+
search: {
|
|
627
|
+
middlewares: [
|
|
628
|
+
({ search, next }) => {
|
|
629
|
+
const result = next(search)
|
|
630
|
+
return {
|
|
631
|
+
rootValue: search.rootValue, // 항상 유지
|
|
632
|
+
...result,
|
|
633
|
+
}
|
|
634
|
+
},
|
|
635
|
+
],
|
|
636
|
+
},
|
|
637
|
+
})
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
### beforeLoad에서 search 정규화
|
|
641
|
+
|
|
642
|
+
```tsx
|
|
643
|
+
export const Route = createFileRoute('/products')({
|
|
644
|
+
validateSearch: z.object({
|
|
645
|
+
page: z.number().catch(1),
|
|
646
|
+
sortBy: z.enum(['asc', 'desc']).catch('asc'),
|
|
647
|
+
}),
|
|
648
|
+
// beforeLoad에서 search 정규화
|
|
649
|
+
beforeLoad: async ({ search }) => {
|
|
650
|
+
// search.page가 음수면 1로 고정
|
|
651
|
+
const sanitized = {
|
|
652
|
+
...search,
|
|
653
|
+
page: Math.max(1, search.page),
|
|
654
|
+
}
|
|
655
|
+
return { sanitized }
|
|
656
|
+
},
|
|
657
|
+
loader: async ({ context }) => {
|
|
658
|
+
// context.sanitized 사용
|
|
659
|
+
return fetchProducts(context.sanitized)
|
|
660
|
+
},
|
|
661
|
+
component: ProductsPage,
|
|
662
|
+
})
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
</search_middleware>
|
|
666
|
+
|
|
667
|
+
---
|
|
668
|
+
|
|
669
|
+
<custom_serialization>
|
|
670
|
+
|
|
671
|
+
## 커스텀 직렬화
|
|
672
|
+
|
|
673
|
+
기본 JSON.stringify/JSON.parse 대신 다른 직렬화 라이브러리 사용 가능.
|
|
674
|
+
|
|
675
|
+
### Base64 인코딩
|
|
676
|
+
|
|
677
|
+
```tsx
|
|
678
|
+
import { parseSearchWith, stringifySearchWith } from '@tanstack/react-router'
|
|
679
|
+
|
|
680
|
+
const router = createRouter({
|
|
681
|
+
routeTree,
|
|
682
|
+
parseSearch: parseSearchWith(value => JSON.parse(decodeFromBinary(value))),
|
|
683
|
+
stringifySearch: stringifySearchWith(value => encodeToBinary(JSON.stringify(value))),
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
// 안전한 바이너리 인코딩/디코딩
|
|
687
|
+
function encodeToBinary(str: string): string {
|
|
688
|
+
return btoa(
|
|
689
|
+
encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, p1) =>
|
|
690
|
+
String.fromCharCode(parseInt(p1, 16)),
|
|
691
|
+
),
|
|
692
|
+
)
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function decodeFromBinary(str: string): string {
|
|
696
|
+
return decodeURIComponent(
|
|
697
|
+
Array.prototype.map
|
|
698
|
+
.call(atob(str), (c) =>
|
|
699
|
+
'%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2),
|
|
700
|
+
)
|
|
701
|
+
.join(''),
|
|
702
|
+
)
|
|
703
|
+
}
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
### JSURL2 (압축 + 가독성)
|
|
707
|
+
|
|
708
|
+
```tsx
|
|
709
|
+
import { parse, stringify } from 'jsurl2'
|
|
710
|
+
|
|
711
|
+
const router = createRouter({
|
|
712
|
+
parseSearch: parseSearchWith(parse),
|
|
713
|
+
stringifySearch: stringifySearchWith(stringify),
|
|
714
|
+
})
|
|
715
|
+
// 결과: ?filters=(author~tanner~min*_words~800)~
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
### query-string
|
|
719
|
+
|
|
720
|
+
```tsx
|
|
721
|
+
import qs from 'query-string'
|
|
722
|
+
|
|
723
|
+
const router = createRouter({
|
|
724
|
+
stringifySearch: stringifySearchWith(value => qs.stringify(value)),
|
|
725
|
+
parseSearch: parseSearchWith(value => qs.parse(value)),
|
|
726
|
+
})
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
</custom_serialization>
|
|
730
|
+
|
|
731
|
+
---
|
|
732
|
+
|
|
733
|
+
<error_handling>
|
|
734
|
+
|
|
735
|
+
## 에러 처리
|
|
736
|
+
|
|
737
|
+
### routerCode 활용
|
|
738
|
+
|
|
739
|
+
```tsx
|
|
740
|
+
export const Route = createFileRoute('/products')({
|
|
741
|
+
validateSearch: z.object({
|
|
742
|
+
page: z.number().min(1).catch(1),
|
|
743
|
+
}),
|
|
744
|
+
errorComponent: ({ error }) => {
|
|
745
|
+
// routerCode로 에러 타입 구분
|
|
746
|
+
if (error.routerCode === 'VALIDATE_SEARCH') {
|
|
747
|
+
return <div>Invalid search parameters. Using defaults.</div>
|
|
748
|
+
}
|
|
749
|
+
return <div>{error.message}</div>
|
|
750
|
+
},
|
|
751
|
+
component: ProductsPage,
|
|
752
|
+
})
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
### 유효하지 않은 값 처리
|
|
756
|
+
|
|
757
|
+
```tsx
|
|
758
|
+
const searchSchema = z.object({
|
|
759
|
+
page: z
|
|
760
|
+
.number()
|
|
761
|
+
.int()
|
|
762
|
+
.positive()
|
|
763
|
+
.catch(1), // 유효하지 않으면 1로 설정
|
|
764
|
+
|
|
765
|
+
sort: z
|
|
766
|
+
.enum(['newest', 'price'])
|
|
767
|
+
.catch('newest'), // 유효하지 않은 enum 값이면 기본값
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
// 또는 명시적 검증
|
|
771
|
+
const strictSearchSchema = z.object({
|
|
772
|
+
page: z.number().int().positive(), // 실패 시 에러
|
|
773
|
+
sort: z.enum(['newest', 'price']),
|
|
774
|
+
}).catch({ page: 1, sort: 'newest' })
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
</error_handling>
|
|
778
|
+
|
|
779
|
+
---
|
|
780
|
+
|
|
781
|
+
<practical_example>
|
|
782
|
+
|
|
783
|
+
## 실전: 필터 + 정렬 + 페이지네이션
|
|
784
|
+
|
|
785
|
+
완전한 구현 예시.
|
|
786
|
+
|
|
787
|
+
### 스키마 정의
|
|
788
|
+
|
|
789
|
+
```tsx
|
|
790
|
+
// /src/routes/posts/-search-schema.ts
|
|
791
|
+
import { z } from 'zod'
|
|
792
|
+
|
|
793
|
+
export const postsSearchSchema = z.object({
|
|
794
|
+
page: z.number().int().positive().catch(1),
|
|
795
|
+
search: z.string().optional(),
|
|
796
|
+
category: z.enum(['tech', 'lifestyle', 'business']).optional(),
|
|
797
|
+
sort: z.enum(['newest', 'oldest', 'popular']).catch('newest'),
|
|
798
|
+
tags: z.array(z.string()).catch([]),
|
|
799
|
+
})
|
|
800
|
+
|
|
801
|
+
export type PostsSearch = z.infer<typeof postsSearchSchema>
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
### 라우트 정의
|
|
805
|
+
|
|
806
|
+
```tsx
|
|
807
|
+
// /src/routes/posts/index.tsx
|
|
808
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
809
|
+
import { postsSearchSchema } from './-search-schema'
|
|
810
|
+
|
|
811
|
+
export const Route = createFileRoute('/posts')({
|
|
812
|
+
validateSearch: postsSearchSchema,
|
|
813
|
+
loaderDeps: ({ search }) => ({ search }),
|
|
814
|
+
loader: async ({ deps: { search } }) => {
|
|
815
|
+
const posts = await fetchPosts({
|
|
816
|
+
page: search.page,
|
|
817
|
+
query: search.search,
|
|
818
|
+
category: search.category,
|
|
819
|
+
sort: search.sort,
|
|
820
|
+
tags: search.tags,
|
|
821
|
+
})
|
|
822
|
+
return posts
|
|
823
|
+
},
|
|
824
|
+
component: PostsPage,
|
|
825
|
+
})
|
|
50
826
|
|
|
51
|
-
// 실전: 필터 + 정렬 + 페이지네이션
|
|
52
827
|
const PostsPage = () => {
|
|
53
|
-
const { page, search, category, sort } = Route.useSearch()
|
|
54
828
|
const posts = Route.useLoaderData()
|
|
829
|
+
const { page, search, category, sort } = Route.useSearch()
|
|
55
830
|
const navigate = useNavigate()
|
|
56
831
|
|
|
57
|
-
const updateSearch = (updates: Partial<
|
|
58
|
-
navigate({
|
|
832
|
+
const updateSearch = (updates: Partial<PostsSearch>) => {
|
|
833
|
+
navigate({
|
|
834
|
+
to: '/posts',
|
|
835
|
+
search: prev => ({ ...prev, ...updates, page: 1 }),
|
|
836
|
+
})
|
|
59
837
|
}
|
|
60
838
|
|
|
61
839
|
return (
|
|
62
|
-
<div>
|
|
63
|
-
|
|
64
|
-
<
|
|
65
|
-
|
|
840
|
+
<div className="space-y-6">
|
|
841
|
+
{/* 검색 입력 */}
|
|
842
|
+
<input
|
|
843
|
+
type="text"
|
|
844
|
+
value={search ?? ''}
|
|
845
|
+
onChange={e => updateSearch({ search: e.target.value })}
|
|
846
|
+
placeholder="Search posts..."
|
|
847
|
+
/>
|
|
848
|
+
|
|
849
|
+
{/* 카테고리 필터 */}
|
|
850
|
+
<select
|
|
851
|
+
value={category ?? ''}
|
|
852
|
+
onChange={e => updateSearch({ category: e.target.value as any })}
|
|
853
|
+
>
|
|
854
|
+
<option value="">All Categories</option>
|
|
66
855
|
<option value="tech">Tech</option>
|
|
856
|
+
<option value="lifestyle">Lifestyle</option>
|
|
857
|
+
<option value="business">Business</option>
|
|
67
858
|
</select>
|
|
68
|
-
|
|
859
|
+
|
|
860
|
+
{/* 정렬 선택 */}
|
|
861
|
+
<select
|
|
862
|
+
value={sort}
|
|
863
|
+
onChange={e => updateSearch({ sort: e.target.value as any })}
|
|
864
|
+
>
|
|
865
|
+
<option value="newest">Newest</option>
|
|
866
|
+
<option value="oldest">Oldest</option>
|
|
867
|
+
<option value="popular">Popular</option>
|
|
868
|
+
</select>
|
|
869
|
+
|
|
870
|
+
{/* 태그 필터 (여러 선택) */}
|
|
871
|
+
<div>
|
|
872
|
+
{['react', 'typescript', 'design'].map(tag => (
|
|
873
|
+
<label key={tag}>
|
|
874
|
+
<input
|
|
875
|
+
type="checkbox"
|
|
876
|
+
checked={tags.includes(tag)}
|
|
877
|
+
onChange={e => {
|
|
878
|
+
const newTags = e.target.checked
|
|
879
|
+
? [...tags, tag]
|
|
880
|
+
: tags.filter(t => t !== tag)
|
|
881
|
+
updateSearch({ tags: newTags })
|
|
882
|
+
}}
|
|
883
|
+
/>
|
|
884
|
+
{tag}
|
|
885
|
+
</label>
|
|
886
|
+
))}
|
|
887
|
+
</div>
|
|
888
|
+
|
|
889
|
+
{/* 포스트 목록 */}
|
|
890
|
+
<div>
|
|
891
|
+
{posts.map(post => (
|
|
892
|
+
<Link key={post.id} to={`/posts/${post.id}`}>
|
|
893
|
+
<h3>{post.title}</h3>
|
|
894
|
+
</Link>
|
|
895
|
+
))}
|
|
896
|
+
</div>
|
|
897
|
+
|
|
898
|
+
{/* 페이지네이션 */}
|
|
899
|
+
<div className="flex gap-2">
|
|
900
|
+
<button
|
|
901
|
+
onClick={() => updateSearch({ page: page - 1 })}
|
|
902
|
+
disabled={page <= 1}
|
|
903
|
+
>
|
|
904
|
+
Previous
|
|
905
|
+
</button>
|
|
906
|
+
<span>Page {page}</span>
|
|
907
|
+
<button onClick={() => updateSearch({ page: page + 1 })}>
|
|
908
|
+
Next
|
|
909
|
+
</button>
|
|
910
|
+
</div>
|
|
69
911
|
</div>
|
|
70
912
|
)
|
|
71
913
|
}
|
|
72
914
|
```
|
|
73
915
|
|
|
74
|
-
</
|
|
916
|
+
</practical_example>
|
|
917
|
+
|
|
918
|
+
---
|
|
919
|
+
|
|
920
|
+
<dos_donts>
|
|
921
|
+
|
|
922
|
+
## Do's & Don'ts
|
|
923
|
+
|
|
924
|
+
| Do | Don't |
|
|
925
|
+
|-------|---------|
|
|
926
|
+
| `validateSearch` 스키마 정의 | 검증 없이 search params 사용 |
|
|
927
|
+
| `Route.useSearch()` (타입 안전) | `useSearch()` + 수동 타입 지정 |
|
|
928
|
+
| `catch()` 또는 `.optional()` 사용 | 필수 필드만 넣기 (URL 없을 수 있음) |
|
|
929
|
+
| `loaderDeps`에 사용하는 값만 포함 | 모든 search 포함 (불필요한 재실행) |
|
|
930
|
+
| 함수로 이전 값 기반 업데이트 | 값을 하드코딩해서 다른 params 덮어쓰기 |
|
|
931
|
+
| `z.enum()` 또는 `z.picklist()` | 문자열 비교로 유효성 확인 |
|
|
932
|
+
| Zod adapter (`zodValidator`) + `fallback()` | raw Zod의 `catch()`로 타입 손실 |
|
|
933
|
+
| `retainSearchParams` / `stripSearchParams` 미들웨어 | search params 수동 전파 |
|
|
934
|
+
| Standard Schema 라이브러리 (Valibot, ArkType) | adapter 없는 복잡한 타입 조작 |
|
|
935
|
+
|
|
936
|
+
</dos_donts>
|