본문으로 건너뛰기

검색 매개변수에서 배열, 객체, 날짜 다루기

타입 안전성과 URL 호환성을 유지하면서 검색 매개변수의 배열, 객체, 날짜 및 중첩 데이터 구조를 다루는 방법을 알아봅니다.

빠른 시작

복잡한 검색 매개변수는 단순한 문자열과 숫자를 넘어섭니다. TanStack Router의 JSON 우선 접근 방식을 사용하면 배열, 객체, 날짜 및 중첩 구조를 쉽게 다룰 수 있습니다.

// Example of complex search parameters
const complexSearch = {
tags: ['typescript', 'react', 'router'], // Array
filters: {
// Nested object
category: 'web',
minRating: 4.5,
active: true,
},
dateRange: {
// Date objects
start: new Date('2024-01-01'),
end: new Date('2024-12-31'),
},
pagination: {
// Nested pagination
page: 1,
size: 20,
sort: { field: 'name', direction: 'asc' },
},
}

배열 다루기

배열은 필터, 태그, 카테고리 및 다중 선택 옵션에 흔히 사용됩니다.

기본 배열 검증

// routes/products.tsx
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'

const searchSchema = z.object({
categories: z.array(z.string()).default([]),
tags: z.array(z.string()).optional(),
priceRange: z.array(z.number()).length(2).optional(), // [min, max]
})

export const Route = createFileRoute('/products')({
validateSearch: searchSchema,
component: ProductsComponent,
})

function ProductsComponent() {
const { categories, tags, priceRange } = Route.useSearch()

return (
<div>
<h2>Active Categories: {categories.join(', ')}</h2>
{tags && <p>Tags: {tags.join(', ')}</p>}
{priceRange && (
<p>
Price: ${priceRange[0]} - ${priceRange[1]}
</p>
)}
</div>
)
}
import { Link } from '@tanstack/react-router'

function FilterControls() {
return (
<div>
{/* Add to existing array */}
<Link
to="/products"
search={(prev) => ({
...prev,
categories: [...(prev.categories || []), 'electronics'],
})}
>
Add Electronics
</Link>

{/* Replace entire array */}
<Link to="/products" search={{ categories: ['books', 'music'] }}>
Books & Music Only
</Link>

{/* Remove from array */}
<Link
to="/products"
search={(prev) => ({
...prev,
categories:
prev.categories?.filter((cat) => cat !== 'electronics') || [],
})}
>
Remove Electronics
</Link>

{/* Clear array */}
<Link to="/products" search={(prev) => ({ ...prev, categories: [] })}>
Clear All
</Link>
</div>
)
}

고급 배열 패턴

// routes/search.tsx
const advancedArraySchema = z.object({
// Array of objects
filters: z
.array(
z.object({
field: z.string(),
operator: z.enum(['eq', 'gt', 'lt', 'contains']),
value: z.union([z.string(), z.number(), z.boolean()]),
}),
)
.default([]),

// Array with constraints
selectedIds: z.array(z.string().uuid()).max(10).default([]),

// Array with transformation
sortFields: z
.array(z.string())
.transform((arr) =>
arr.filter((field) => ['name', 'date', 'price'].includes(field)),
)
.default(['name']),
})

export const Route = createFileRoute('/search')({
validateSearch: advancedArraySchema,
component: SearchComponent,
})

객체 다루기

객체는 그룹화된 매개변수, 복잡한 필터 및 중첩 구성에 유용합니다.

기본 객체 검증

// routes/dashboard.tsx
const dashboardSchema = z.object({
view: z
.object({
layout: z.enum(['grid', 'list', 'cards']).default('grid'),
columns: z.number().min(1).max(6).default(3),
showDetails: z.boolean().default(false),
})
.prefault({}),

filters: z
.object({
status: z.enum(['active', 'inactive', 'pending']).optional(),
dateCreated: z
.object({
after: z.string().optional(),
before: z.string().optional(),
})
.optional(),
metadata: z.record(z.string(), z.string()).optional(), // Dynamic object keys
})
.prefault({}),
})

export const Route = createFileRoute('/dashboard')({
validateSearch: dashboardSchema,
component: DashboardComponent,
})

function DashboardComponent() {
const { view, filters } = Route.useSearch()

return (
<div>
<div className={`layout-${view.layout} columns-${view.columns}`}>
{/* Render based on complex object state */}
</div>

{filters.status && <p>Status: {filters.status}</p>}
{filters.dateCreated?.after && (
<p>Created after: {filters.dateCreated.after}</p>
)}
</div>
)
}
function ViewControls() {
return (
<div>
{/* Update nested object property */}
<Link
to="/dashboard"
search={(prev) => ({
...prev,
view: {
...prev.view,
layout: 'list',
},
})}
>
List View
</Link>

{/* Update multiple nested properties */}
<Link
to="/dashboard"
search={(prev) => ({
...prev,
view: {
...prev.view,
layout: 'grid',
columns: 4,
showDetails: true,
},
})}
>
4-Column Grid with Details
</Link>

{/* Deep merge with library for complex updates */}
<Link
to="/dashboard"
search={(prev) =>
merge(prev, {
filters: {
dateCreated: { after: '2024-01-01' },
},
})
}
>
Filter Recent Items
</Link>
</div>
)
}

// For deep merging, use a well-tested library:

// Option 1: Lodash (most popular, full-featured)
// npm install lodash-es
// import { merge } from 'lodash-es'

// Option 2: deepmerge (lightweight, focused)
// npm install deepmerge
// import merge from 'deepmerge'

// Option 3: Ramda (functional programming style)
// npm install ramda
// import { mergeDeepRight as merge } from 'ramda'

// Example with deepmerge (recommended for most cases):
import merge from 'deepmerge'

// Handles arrays intelligently - combines by default
const result = merge(
{ filters: { tags: ['react'] } },
{ filters: { tags: ['typescript'] } },
)
// Result: { filters: { tags: ['react', 'typescript'] } }

// Override array merging behavior if needed
const overwriteResult = merge(
{ filters: { tags: ['react'] } },
{ filters: { tags: ['typescript'] } },
{ arrayMerge: (dest, source) => source }, // Overwrite instead of combine
)
// Result: { filters: { tags: ['typescript'] } }

날짜 다루기

날짜는 URL 직렬화 및 검증을 위해 특별히 처리해야 합니다.

날짜 검증 및 직렬화

// routes/events.tsx
const eventSchema = z.object({
// ISO string dates
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),

// Date range as object
dateRange: z
.object({
start: z.string().datetime(),
end: z.string().datetime(),
})
.optional(),

// Transform string to Date object
selectedDate: z
.string()
.datetime()
.transform((str) => new Date(str))
.optional(),

// Relative dates
timeFilter: z.enum(['today', 'week', 'month', 'year']).default('week'),
})

export const Route = createFileRoute('/events')({
validateSearch: eventSchema,
component: EventsComponent,
})

function EventsComponent() {
const search = Route.useSearch()

// Convert string dates back to Date objects for display
const startDate = search.startDate ? new Date(search.startDate) : null
const endDate = search.endDate ? new Date(search.endDate) : null

return (
<div>
{startDate && <p>Events from: {startDate.toLocaleDateString()}</p>}
{search.selectedDate && (
<p>Selected: {search.selectedDate.toLocaleDateString()}</p>
)}
</div>
)
}

날짜 탐색 패턴

function DateControls() {
const navigate = useNavigate()

const setDateRange = (start: Date, end: Date) => {
navigate({
to: '/events',
search: (prev) => ({
...prev,
dateRange: {
start: start.toISOString(),
end: end.toISOString(),
},
}),
})
}

const setRelativeDate = (period: string) => {
const now = new Date()
let start: Date

switch (period) {
case 'today':
start = new Date(now.getFullYear(), now.getMonth(), now.getDate())
break
case 'week':
start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
break
case 'month':
start = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate())
break
default:
start = now
}

setDateRange(start, now)
}

return (
<div>
<button onClick={() => setRelativeDate('today')}>Today</button>
<button onClick={() => setRelativeDate('week')}>Past Week</button>
<button onClick={() => setRelativeDate('month')}>Past Month</button>

{/* Date picker integration */}
<input
type="date"
onChange={(e) => {
const date = new Date(e.target.value)
navigate({
to: '/events',
search: (prev) => ({
...prev,
selectedDate: date.toISOString(),
}),
})
}}
/>
</div>
)
}

중첩 데이터 구조

복잡한 애플리케이션에는 깊게 중첩된 검색 매개변수가 필요한 경우가 많습니다.

복잡한 중첩 스키마

// routes/analytics.tsx
const analyticsSchema = z.object({
dashboard: z
.object({
widgets: z
.array(
z.object({
id: z.string(),
type: z.enum(['chart', 'table', 'metric']),
config: z.object({
title: z.string(),
dataSource: z.string(),
filters: z.array(
z.object({
field: z.string(),
operator: z.string(),
value: z.any(),
}),
),
visualization: z
.object({
chartType: z.enum(['line', 'bar', 'pie']).optional(),
colors: z.array(z.string()).optional(),
axes: z
.object({
x: z.string(),
y: z.array(z.string()),
})
.optional(),
})
.optional(),
}),
}),
)
.default([]),

layout: z
.object({
columns: z.number().min(1).max(12).default(2),
gap: z.number().default(16),
responsive: z.boolean().default(true),
})
.prefault({}),

timeRange: z
.object({
preset: z.enum(['1h', '24h', '7d', '30d', 'custom']).default('24h'),
custom: z
.object({
start: z.string().datetime(),
end: z.string().datetime(),
})
.optional(),
})
.prefault({}),
})
.prefault({}),
})

export const Route = createFileRoute('/analytics')({
validateSearch: analyticsSchema,
component: AnalyticsComponent,
})

복잡한 상태 업데이트 관리

function AnalyticsControls() {
const search = Route.useSearch()
const navigate = useNavigate()

// Helper to update nested widget config
const updateWidgetConfig = (widgetId: string, configUpdate: any) => {
navigate({
to: '/analytics',
search: (prev) => ({
...prev,
dashboard: {
...prev.dashboard,
widgets: prev.dashboard.widgets.map((widget) =>
widget.id === widgetId
? {
...widget,
config: { ...widget.config, ...configUpdate },
}
: widget,
),
},
}),
})
}

// Helper to add new widget
const addWidget = (widget: any) => {
navigate({
to: '/analytics',
search: (prev) => ({
...prev,
dashboard: {
...prev.dashboard,
widgets: [...prev.dashboard.widgets, widget],
},
}),
})
}

// Helper to update layout
const updateLayout = (layoutUpdate: any) => {
navigate({
to: '/analytics',
search: (prev) => ({
...prev,
dashboard: {
...prev.dashboard,
layout: { ...prev.dashboard.layout, ...layoutUpdate },
},
}),
})
}

return (
<div>
<button onClick={() => updateLayout({ columns: 3 })}>3 Columns</button>

<button
onClick={() =>
addWidget({
id: Date.now().toString(),
type: 'chart',
config: {
title: 'New Chart',
dataSource: 'default',
filters: [],
},
})
}
>
Add Chart Widget
</button>
</div>
)
}

성능 최적화

셀렉터를 사용한 선택적 업데이트

// Only re-render when specific nested values change
function WidgetComponent({ widgetId }: { widgetId: string }) {
// Use selector to avoid unnecessary re-renders
const widget = Route.useSearch({
select: (search) => search.dashboard.widgets.find((w) => w.id === widgetId),
})

const layout = Route.useSearch({
select: (search) => search.dashboard.layout,
})

if (!widget) return null

return (
<div
style={{
gridColumn: `span ${Math.ceil(12 / layout.columns)}`,
}}
>
<h3>{widget.config.title}</h3>
{/* Widget content */}
</div>
)
}

복잡한 변환 메모이제이션

import { useMemo } from 'react'

function ComplexDataComponent() {
const search = Route.useSearch()

// Memoize expensive transformations
const processedData = useMemo(() => {
return search.dashboard.widgets
.filter((widget) => widget.type === 'chart')
.map((widget) => ({
...widget,
computedMetrics: expensiveCalculation(widget.config),
}))
}, [search.dashboard.widgets])

return (
<div>
{processedData.map((widget) => (
<ComplexChart key={widget.id} data={widget} />
))}
</div>
)
}

프로덕션 체크리스트

  • 배열 범위 검증 - .min(), .max(), .length() 제약 조건을 사용합니다.
  • 날짜 형식 일관성 - URL 호환성을 위해 ISO 문자열을 사용합니다.
  • 객체 깊이 제한 - URL 길이를 고려해 과도하게 중첩된 구조를 피합니다.
  • 성능 테스트 - 검색 매개변수에서 큰 배열/객체를 사용해 테스트합니다.
  • URL 길이 제한 - 대부분의 브라우저는 URL을 약 2000자로 제한합니다.
  • 대체 값 - 모든 복잡한 타입에 합리적인 기본값을 제공합니다.
  • 타입 안전성 - 스키마가 컴포넌트의 예상과 일치하는지 확인합니다.
  • 직렬화 테스트 - 왕복 직렬화가 올바르게 작동하는지 확인합니다.

일반적인 문제

문제: 배열 매개변수가 업데이트되지 않음

증상: Link를 클릭해도 배열 검색 매개변수가 업데이트되지 않습니다.

원인: 새 배열을 만드는 대신 배열을 직접 변경합니다.

해결 방법: 업데이트할 때는 항상 새 배열을 만듭니다.

// ❌ Wrong - mutates existing array
search={(prev) => {
prev.categories.push('new-item')
return prev
}}

// ✅ Correct - creates new array
search={(prev) => ({
...prev,
categories: [...prev.categories, 'new-item']
})}

문제: 날짜가 올바르게 직렬화되지 않음

증상: URL에서 날짜 객체가 [object Object]가 됩니다.

원인: 날짜 객체를 직접 직렬화하려고 합니다.

해결 방법: 날짜를 ISO 문자열로 변환합니다.

// ❌ Wrong - Date objects don't serialize
search={{
startDate: new Date() // Becomes "[object Object]"
}}

// ✅ Correct - Use ISO strings
search={{
startDate: new Date().toISOString()
}}

문제: 깊은 객체 업데이트가 작동하지 않음

증상: 중첩 객체 속성이 예상대로 업데이트되지 않습니다.

원인: 얕은 병합으로는 중첩 속성이 업데이트되지 않습니다.

해결 방법: 적절한 깊은 병합이나 전개 연산자를 사용합니다.

// ❌ Wrong - shallow merge loses nested properties
search={(prev) => ({
...prev,
filters: { category: 'new' } // Loses other filter properties
})}

// ✅ Correct - preserve nested properties
search={(prev) => ({
...prev,
filters: {
...prev.filters,
category: 'new'
}
})}

문제: URL이 너무 긴 오류

증상: 매우 복잡한 검색 매개변수에서 브라우저 오류가 발생합니다.

원인: 브라우저 URL 길이 제한(약 2000자)을 초과합니다.

해결 방법:

  1. 데이터 구조 단순화 - 불필요한 중첩을 제거합니다.
  2. 압축 사용 - 사용자 지정 직렬화를 구현합니다.
  3. 세션에 저장 - URL 키와 함께 복잡한 상태를 sessionStorage에 유지합니다.
  4. 페이지 매김 - 큰 배열을 여러 페이지로 나눕니다.
// Option 3: Session storage approach
const sessionKey = Route.useSearch({ select: (s) => s.sessionKey })
const complexData = useMemo(() => {
if (sessionKey) {
return JSON.parse(sessionStorage.getItem(sessionKey) || '{}')
}
return {}
}, [sessionKey])

문제: 큰 객체의 성능 문제

증상: 복잡한 검색 매개변수에서 탐색과 다시 렌더링이 느립니다.

원인: 큰 객체로 인해 직렬화 및 비교 작업에 많은 비용이 듭니다.

해결 방법:

  1. 다시 렌더링을 제한하도록 셀렉터를 사용합니다.
  2. 비용이 큰 계산을 메모이제이션합니다.
  3. context나 상태 관리와 같은 대안을 고려합니다.
// Use selector to minimize re-renders
const onlyNeededData = Route.useSearch({
select: (search) => ({
currentPage: search.pagination.page,
pageSize: search.pagination.size,
}),
})

일반적인 다음 단계

깊은 병합 라이브러리:

  • deepmerge - 가볍고 목적이 분명한 깊은 병합 유틸리티
  • Lodash merge - 깊은 병합을 지원하는 모든 기능을 갖춘 유틸리티 라이브러리
  • Ramda mergeDeepRight - 함수형 프로그래밍 접근 방식