Learn to handle arrays, objects, dates, and nested data structures in search parameters while maintaining type safety and URL compatibility.
Complex search parameters go beyond simple strings and numbers. TanStack Router's JSON-first approach makes it easy to handle arrays, objects, dates, and nested structures:
// 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' },
},
}
Arrays are commonly used for filters, tags, categories, and multi-select options.
// 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,
})
Objects are useful for grouped parameters, complex filters, and nested configurations.
// 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),
})
.default({}),
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()).optional(), // Dynamic object keys
})
.default({}),
})
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'] } }
Dates require special handling for URL serialization and validation.
// 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>
)
}
Complex applications often need deeply nested search parameters.
// 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),
})
.default({}),
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(),
})
.default({}),
})
.default({}),
})
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>
)
}
Symptoms: Link clicks don't update array search parameters.
Cause: Directly mutating arrays instead of creating new ones.
Solution: Always create new arrays when updating:
// ❌ 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']
})}
Symptoms: Date objects become [object Object] in URL.
Cause: Attempting to serialize Date objects directly.
Solution: Convert dates to ISO strings:
// ❌ Wrong - Date objects don't serialize
search={{
startDate: new Date() // Becomes "[object Object]"
}}
// ✅ Correct - Use ISO strings
search={{
startDate: new Date().toISOString()
}}
Symptoms: Nested object properties don't update as expected.
Cause: Shallow merging doesn't update nested properties.
Solution: Use proper deep merging or spread operators:
// ❌ 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'
}
})}
Symptoms: Browser errors with very complex search parameters.
Cause: Exceeding browser URL length limits (~2000 characters).
Solutions:
// 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])
Symptoms: Slow navigation and re-renders with complex search parameters.
Cause: Large objects causing expensive serialization and comparison operations.
Solutions:
// Use selector to minimize re-renders
const onlyNeededData = Route.useSearch({
select: (search) => ({
currentPage: search.pagination.page,
pageSize: search.pagination.size,
}),
})
Deep Merging Libraries: