Skip to content

Routing

Reatom provides a powerful routing system that handles URL management, parameter validation, and data loading with automatic memory management. This guide covers everything from basic routing to advanced patterns.

Here’s a minimal example to get you started:

src/routes.ts
import { reatomRoute } from '@reatom/core'
export const homeRoute = reatomRoute('')
export const aboutRoute = reatomRoute('about')
// Navigate programmatically
homeRoute.go()
aboutRoute.go()
src/App.tsx
import { reatomComponent } from '@reatom/react'
import { homeRoute, aboutRoute } from './routes'
export const App = reatomComponent(
() => (
<div>
<nav>
<button onClick={wrap(() => homeRoute.go())}>Home</button>
<button onClick={wrap(() => aboutRoute.go())}>About</button>
</nav>
{homeRoute.exact() && <h1>Home Page</h1>}
{aboutRoute.exact() && <h1>About Page</h1>}
</div>
),
'App',
)

That’s it! Routes automatically sync with the browser URL and history.

Routes are atoms that return parameters when matched, or null when not matched:

import { reatomRoute } from '@reatom/core'
const userRoute = reatomRoute('users/:userId')
userRoute()

When the URL is /users/123, userRoute() returns { userId: '123' }. When the URL is anything else, userRoute() returns null.

Routes can match partially (prefix) or exactly:

const usersRoute = reatomRoute('users')
const userRoute = reatomRoute('users/:userId')
// At URL: /users/123
usersRoute() // { } - matches partially
usersRoute.exact() // false - not an exact match
userRoute() // { userId: '123' }
userRoute.exact() // true - exact match

Use .exact() when you only want to render content for that specific route:

// Only show on /users, not /users/123
{
usersRoute.exact() && <UserList />
}
// Show on both /users/123 and /users/123/edit
{
userRoute() && <UserBreadcrumb userId={userRoute().userId} />
}

Navigate using the .go() method:

// Navigate with parameters
userRoute.go({ userId: '123' })
// Navigate without parameters
homeRoute.go()
// Navigate with type safety - TypeScript error if wrong params
userRoute.go({ userId: 123 }) // ❌ Error: userId must be string

You can also use urlAtom directly for raw URL changes:

import { urlAtom } from '@reatom/core'
// Navigate to any URL
urlAtom.go('/some/path')
urlAtom.go('/users/123?tab=posts')
// Read current URL
const { pathname, search, hash } = urlAtom()

Define dynamic segments with :paramName:

// Required parameter
const userRoute = reatomRoute('users/:userId')
// Optional parameter (note the ?)
const postRoute = reatomRoute('posts/:postId?')
postRoute.go({}) // → /posts
postRoute.go({ postId: '42' }) // → /posts/42

Use .path() to build URLs without navigating:

const userRoute = reatomRoute('users/:userId')
const url = userRoute.path({ userId: '123' })
// url === '/users/123'
// Use in links
<a href={userRoute.path({ userId: '123' })}>View User</a>

You might think, “hmm, but this is going to be a native link with regular browser navigation”, but this is not the case: by default, urlAtom intercepts clicks on any <a> links and makes SPA navigation. You can disable this behavior globally in the entry point of your app like this:

urlAtom.catchLinks(false)

Build route hierarchies by chaining .reatomRoute():

src/routes.ts
import { reatomRoute } from '@reatom/core'
export const dashboardRoute = reatomRoute('dashboard')
export const usersRoute = dashboardRoute.reatomRoute('users')
export const userRoute = usersRoute.reatomRoute(':userId')
export const userEditRoute = userRoute.reatomRoute('edit')
// At URL: /dashboard/users/123/edit
dashboardRoute() // { }
usersRoute() // { }
userRoute() // { userId: '123' }
userEditRoute() // { userId: '123' }
dashboardRoute.exact() // false
usersRoute.exact() // false
userRoute.exact() // false
userEditRoute.exact() // true

Nested routes inherit parent parameters:

// Navigate to /dashboard/users/123/edit
userEditRoute.go({ userId: '123' })
// All parent routes automatically match
dashboardRoute() // { }
usersRoute() // { }
userRoute() // { userId: '123' }

Reatom routing provides a framework-agnostic component composition pattern through the render option. This allows you to define components directly in your routes and compose them hierarchically, with automatic mounting/unmounting management - no framework coupling required!

Each route may have a render function that returns a component. This component will be added automatically in the parent outlet list when the route is active/inactive.

import { reatomRoute } from '@reatom/core'
const layoutRoute = reatomRoute({
render({ outlet }) {
return html`<div>
<header>My App</header>
<main>${outlet().map((child) => child)}</main>
<footer>© 2025</footer>
</div>`
},
})
const aboutRoute = layoutRoute.reatomRoute({
path: 'about',
render() {
return html`<article>
<h1>About</h1>
<p>Welcome to our app!</p>
</article>`
},
})
const userRoute = layoutRoute.reatomRoute({
path: 'user/:userId',
async loader({ userId }) {
return api.getUser(userId)
},
render() {
if (!userRoute.loader.ready()) return html`<div>Loading...</div>`
const user = userRoute.loader.data()
return html`<article>
<h1>${user.name}</h1>
<p>${user.bio}</p>
</article>`
},
})

Render your entire app by calling .render() on the root route:

const App = computed(() => {
return html`<div>${layoutRoute.render()}</div>`
})

How it works:

  1. render option - Each route can define a render function that returns your component (string, object, or any type)
  2. outlet() computed - Returns an array of all active child routes’ rendered components
  3. Automatic rendering - When the URL matches a route, its render() is called and added to parent’s outlet()
  4. Memory management - Components are automatically cleaned up when routes become inactive
  5. Layout routes - Routes can omit the path to act as pure layout wrappers (always active)

Key benefits:

  • Framework-agnostic - Works with any rendering approach (tagged templates, JSX, hyperscript, etc.)
  • Declarative composition - Route hierarchy defines component hierarchy
  • Automatic cleanup - No manual lifecycle management needed
  • Type-safe - Ability to define custom types for your framework

The RouteChild type can be redeclared for your framework:

// For React/Preact
declare module '@reatom/core' {
interface RouteChild extends JSX.Element {}
}
// For Vue
declare module '@reatom/core' {
interface RouteChild extends VNode {}
}
// For Lit
declare module '@reatom/core' {
interface RouteChild extends TemplateResult {}
}

Here is how it would look like in React:

IMPORTANT NOTE: you cannot use hooks inside render function, because it’s not a React component.

import { reatomRoute } from '@reatom/core'
import { reatomComponent } from '@reatom/react'
const layoutRoute = reatomRoute({
render({ outlet }) {
return (
<div>
<header>My App</header>
<main>{outlet().map((child) => child)}</main>
<footer>© 2025</footer>
</div>
)
},
})
const About = React.lazy(() => import('./About'))
const aboutRoute = layoutRoute.reatomRoute({
path: 'about',
render() {
return (
<Suspense fallback={<div>Loading...</div>}>
<About />
</Suspense>
)
},
})
const App = reatomComponent(() => {
return <div>{layoutRoute.render()}</div>
})

If you see “‘render’ implicitly has return type ‘any’ because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.ts(7023)” you just need to type the render function implicitly:

const layoutRoute = reatomRoute({
async loader() {
/* ... */
},
render({ outlet }): RouteChild {
if (!layoutRoute.loader.ready()) return <Loader />
return (
<div>
<header>My App</header>
<main>{outlet().map((child) => child)}</main>
<footer>© 2025</footer>
</div>
)
},
})

You can define, validate and transform parameters using Zod schemas or other Standard Schema compatible validation:

import { reatomRoute } from '@reatom/core'
import { z } from 'zod/v4'
export const userRoute = reatomRoute({
path: 'users/:userId',
params: z.object({
userId: z.string().regex(/^\d+$/).transform(Number),
}),
})
// Type-safe: userId is now a number
userRoute.go({ userId: '123' }) // ✅ Valid
userRoute.go({ userId: 'abc' }) // ❌ Throws validation error
// At URL: /users/123
const params = userRoute()
params.userId // Type: number, Value: 123

If validation fails, the route returns null:

// At URL: /users/invalid
userRoute() // null (validation failed)

Define query string parameters with the search option:

export const searchRoute = reatomRoute({
path: 'search',
search: z.object({
q: z.string().optional(),
page: z.string().regex(/^\d+$/).transform(Number).default(1),
sort: z.enum(['asc', 'desc']).optional(),
}),
})
// Navigate with query params
searchRoute.go({ q: 'reatom', page: 2, sort: 'desc' })
// URL: /search?q=reatom&page=2&sort=desc
// At URL: /search?q=reatom
const params = searchRoute()
params.q // 'reatom'
params.page // 1 (default applied)
params.sort // undefined

Routes can have only search parameters with no path. These are useful for global overlays like modals or filters.

A search-only route preserves the current pathname:

export const dialogRoute = reatomRoute({
search: z.object({
dialog: z.enum(['login', 'signup']).optional(),
}),
})
// User is at /profile/123
dialogRoute.go({ dialog: 'login' })
// URL: /profile/123?dialog=login (pathname preserved)
// Navigate elsewhere
urlAtom.go('/settings')
// dialogRoute() still works: reads ?dialog param from any URL
// Close dialog
dialogRoute.go({})
// URL: /settings (search params cleared)

This is perfect for modals that work across your entire app:

export const LoginDialog = reatomComponent(() => {
const params = dialogRoute()
if (params?.dialog !== 'login') return null
return (
<dialog open>
<h2>Login</h2>
<button onClick={wrap(() => dialogRoute.go({}))}>Close</button>
</dialog>
)
}, 'LoginDialog')

Search-only routes under a parent navigate to the parent’s path:

const settingsRoute = reatomRoute('settings')
const settingsDialogRoute = settingsRoute.reatomRoute({
search: z.object({
dialog: z.enum(['export', 'import']).optional(),
}),
})
// User is at /home
settingsDialogRoute.go({ dialog: 'export' })
// URL: /settings?dialog=export (navigates to parent path)
// User is at /settings/profile
settingsDialogRoute.go({ dialog: 'import' })
// URL: /settings/profile?dialog=import (preserves sub-path)

Use cases:

  • Modal dialogs scoped to specific sections
  • Filters that persist across related pages
  • Authentication overlays
  • Settings panels

Be careful not to use the same name in both path and search parameters:

const badRoute = reatomRoute({
path: 'posts/:id',
search: z.record(z.string()), // Accepts any query param including 'id'
})
// At URL: /posts/123?id=456
badRoute() // ❌ Throws: "Params collision"

Keep parameter names unique or use strict search schemas:

const goodRoute = reatomRoute({
path: 'posts/:postId',
search: z.object({
commentId: z.string().optional(),
}),
})

Loaders automatically fetch data when a route becomes active:

import { reatomRoute, wrap } from '@reatom/core'
import { z } from 'zod'
export const userRoute = reatomRoute({
path: 'users/:userId',
params: z.object({
userId: z.string().regex(/^\d+$/).transform(Number),
}),
async loader(params) {
const user = await wrap(
fetch(`/api/users/${params.userId}`).then((r) => r.json()),
)
return user
},
})

The loader automatically provides async state tracking:

export const UserPage = reatomComponent(() => {
const params = userRoute()
if (!params) return null
const ready = userRoute.loader.ready()
const user = userRoute.loader.data()
const error = userRoute.loader.error()
if (!ready) return <div>Loading user {params.userId}...</div>
if (error)
return (
<div>
Error: {error.message}
<button onClick={wrap(userRoute.loader.reset)}>Retry</button>
</div>
)
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
)
}, 'UserPage')

Since route loader it’s an async computed, you can access the same properties that available with withAsyncData extension:

  • loader.ready() - Boolean atom that is true when data has loaded successfully
  • loader.data() - Atom with the loaded data (throws if not ready)
  • loader.error() - Atom with error if loading failed, null otherwise
  • loader.retry() - Action to trigger a retry for loader, that will rerun the loader function

If you don’t provide a loader but do provide validation schemas, a default loader returns the validated parameters:

const searchRoute = reatomRoute({
path: 'search',
search: z.object({
q: z.string(),
page: z.number().default(1),
}),
})
// Default loader returns validated params
const params = await wrap(searchRoute.loader())
params.q // string
params.page // number

Child route loaders can access parent params:

const userRoute = reatomRoute({
path: 'users/:userId',
params: z.object({
userId: z.string().transform(Number),
}),
async loader(params) {
const user = await wrap(
fetch(`/api/users/${params.userId}`).then((r) => r.json()),
)
return user
},
})
const userPostsRoute = userRoute.reatomRoute({
path: 'posts',
// loaders params includes parent route params
async loader({ userId }) {
const posts = await wrap(
fetch(`/api/users/${userId}/posts`).then((r) => r.json()),
)
return posts
},
})

Loaders are automatically aborted when navigating away:

const lazyRoute = reatomRoute({
path: 'dashboard',
async loader() {
// This effect runs while the route is active and as long as the louder's
// dependencies do not change (its parameters or any other atoms reactively
// called inside this callback)
effect(async () => {
while (true) {
await wrap(sleep(5000))
// Doing retry every 5 seconds there, just a regular pooling implementation
lazyRoute.loader.retry()
}
})
// Long-running fetch that will also be aborted with the effect above
const data = await wrap(fetch('/api/dashboard').then((r) => r.json()))
return data
},
})
// Navigate away
someOtherRoute.go({})
// ✅ Loader fetch and effects are automatically aborted
import { reatomComponent } from '@reatom/react'
import { homeRoute } from '../routes'
export const HomePage = reatomComponent(() => {
if (!homeRoute.exact()) return null
return (
<div>
<h1>Welcome Home!</h1>
</div>
)
}, 'HomePage')
import { reatomComponent } from '@reatom/react'
import { userRoute } from '../routes'
export const UserPage = reatomComponent(() => {
const params = userRoute()
if (!params) return null
const ready = userRoute.loader.ready()
const user = userRoute.loader.data()
const error = userRoute.loader.error()
if (!ready) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
)
}, 'UserPage')
import { reatomComponent } from '@reatom/react'
import { HomePage } from './components/HomePage'
import { UserPage } from './components/UserPage'
import { homeRoute, userRoute } from './routes'
export const App = reatomComponent(
() => (
<div>
<nav>
<button onClick={wrap(() => homeRoute.go({}))}>Home</button>
<button onClick={wrap(() => userRoute.go({ userId: '1' }))}>
User 1
</button>
<button onClick={wrap(() => userRoute.go({ userId: '2' }))}>
User 2
</button>
</nav>
<main>
<HomePage />
<UserPage />
</main>
</div>
),
'App',
)

Track if any route is loading using the route registry:

import { urlAtom, computed } from '@reatom/core'
export const isAnyRouteLoading = computed(() => {
return Object.values(urlAtom.routes).some((route) => !route.loader.ready())
}, 'isAnyRouteLoading')
export const GlobalLoader = reatomComponent(() => {
const loading = isAnyRouteLoading()
if (!loading) return null
return <div className="loading-bar">Loading...</div>
}, 'GlobalLoader')

All routes are automatically registered in urlAtom.routes, making it easy to create global loading indicators or debug route state.

One of Reatom’s most powerful features is creating state inside route loaders. This solves the classic state management problem: automatic memory management in global state.

  • Local state (useState) has automatic cleanup but suffers from prop drilling
  • Global state is easy to share but requires manual memory management

Create atoms inside computeds (like route loaders) for automatic cleanup:

import {
reatomRoute,
reatomForm,
computed,
isShallowEqual,
deatomize,
wrap,
} from '@reatom/core'
import { z } from 'zod'
const userRoute = reatomRoute({
path: 'users/:userId',
params: z.object({
userId: z.string().transform(Number),
}),
async loader(params) {
const user = await wrap(
fetch(`/api/users/${params.userId}`).then((r) => r.json()),
)
return user
},
})
export const userEditRoute = userRoute.reatomRoute({
path: 'edit',
async loader(params) {
const user = userRoute.loader.data()
// Create a form INSIDE the loader
// It will be automatically cleaned up when the route changes
const editForm = reatomForm(
{ name: user.name, bio: user.bio },
{
onSubmit: async (values) => {
if (editForm.focus().dirty) {
await wrap(
fetch(`/api/users/${user.id}`, {
method: 'PUT',
body: JSON.stringify(values),
}),
)
}
},
name: `userEditForm#${user.id}`,
},
)
return {
user,
editForm,
}
},
})

Now access the form globally from any component:

export const UserEditPage = reatomComponent(() => {
const params = userEditRoute()
if (!params) return null
const ready = userEditRoute.loader.ready()
const data = userEditRoute.loader.data()
const error = userEditRoute.loader.error()
if (!ready) return <div>Loading editor...</div>
if (error) return <div>Error: {error.message}</div>
const { editForm } = data
return (
<form
onSubmit={(e) => {
e.preventDefault()
editForm.submit().catch(noop)
}}
>
<input name="name" {...bindField(editForm.fields.name)} />
<textarea name="bio" {...bindField(editForm.fields.bio)} />
{editForm.focus().dirty && <div>⚠️ Unsaved changes</div>}
<button type="submit" disabled={!editForm.submit.ready()}>
Save
</button>
</form>
)
}, 'UserEditPage')

When navigating from /users/123/edit to /users/456/edit:

  1. Loader executes with { userId: 456 }
  2. New form is created for user 456
  3. Previous form for user 123 is automatically garbage collected
  4. No memory leaks, no manual cleanup

This gives you:

  • ✅ Global accessibility (no prop drilling)
  • ✅ Automatic memory management (like local state)
  • ✅ Perfect type inference
  • ✅ No manual lifecycle management

Create sophisticated interconnected systems:

import { reatomRoute, reatomForm, computed, effect, wrap } from '@reatom/core'
export const dashboardRoute = reatomRoute({
path: 'dashboard',
async loader() {
// Load initial data
const user = await wrap(fetch('/api/user').then((r) => r.json()))
// Create multiple forms
const profileForm = reatomForm(user.profile, {
onSubmit: async (values) => {
await wrap(
fetch('/api/profile', {
method: 'PUT',
body: JSON.stringify(values),
}),
)
},
})
const settingsForm = reatomForm(user.settings, {
onSubmit: async (values) => {
await wrap(
fetch('/api/settings', {
method: 'PUT',
body: JSON.stringify(values),
}),
)
},
})
// Creating an async action to fetch stats
const fetchStats = action(async () => {
const stats = await wrap(fetch('/api/stats').then((r) => r.json()))
return stats
}).extend(withAsyncData())
// Polling effect that runs while route is active
effect(async () => {
while (true) {
await wrap(sleep(30_000))
fetchStats()
}
})
// Derived state across multiple systems
const dashboardState = computed(() => {
const stats = fetchStats.data()
return {
isProfileComplete: !!(
profileForm.fields.name() && profileForm.fields.email()
),
dirtyFormsCount: [profileForm, settingsForm].filter(
(f) => f.focus().dirty,
).length,
hasNotifications: stats ? stats.notifications > 0 : null,
}
})
return {
user,
stats: fetchStats.data,
profileForm,
settingsForm,
dashboardState,
}
},
})

This pattern provides:

  • No global singletons - fresh state for each route activation
  • No manual cleanup - automatic garbage collection
  • No state pollution - clean slate on navigation
  • Perfect composition - factories can create any state structure
  • Type safety - complete inference through the chain

Loader Memoization and Stable Model Creation

Section titled “Loader Memoization and Stable Model Creation”

When using the factory pattern in route loaders, there’s an important consideration: loaders recompute whenever route parameters change. This includes both path parameters and search parameters. If you create models (data fetching, computed atoms, etc.) directly in the loader, they will be recreated on every parameter change, causing you to lose their previous state.

The Problem:

Consider a todo app with tabs for filtering (all, open, closed). The todos list should only be fetched once, but we need a filtered view based on the active tab:

const todosRoute = reatomRoute({
path: 'todos',
search: z.object({
tab: z.enum(['all', 'open', 'closed']).optional(),
}),
async loader(params) {
// ❌ Problem: This fetch runs every time `tab` changes!
// The todos list is refetched unnecessarily
const todos = await wrap(fetch('/api/todos').then((r) => r.json()))
// ❌ This computed is recreated on every tab change
const filteredList = computed(() => {
const tab = params.tab || 'all'
if (tab === 'all') return todos
if (tab === 'open') return todos.filter((t) => !t.completed)
return todos.filter((t) => t.completed)
}, 'filteredList')
return { todos, filteredList }
},
})

When the user changes the tab search parameter (e.g., from ?tab=all to ?tab=open), the loader recomputes, refetching the todos list unnecessarily and recreating the filtered list computed.

Extract search parameters into separate atoms using searchParamsAtom or withSearchParams. This prevents the loader from recomputing when only search params change. Create the filtered list as a separate computed that depends on both the todos data and the tab.

import { atom, computed, withSearchParams } from '@reatom/core'
import { z } from 'zod'
const todosRoute = reatomRoute({
path: 'todos',
// No search schema in route - handle separately
async loader(params) {
// ✅ Fetch only happens when route becomes active, not on tab changes
const todos = await wrap(fetch('/api/todos').then((r) => r.json()))
return { todos }
},
})
// Separate atom for tab search param
const todosTab = atom<'all' | 'open' | 'closed'>('all', 'todosTab').extend(
withSearchParams('tab', (value) => {
if (value === 'all' || value === 'open' || value === 'closed') {
return value
}
return 'all'
}),
)
// ✅ Filtered list as separate computed that depends on both todos and tab
const filteredTodos = computed(() => {
const todos = todosRoute.loader.data()?.todos || []
const tab = todosTab()
if (tab === 'all') return todos
if (tab === 'open') return todos.filter((t) => !t.completed)
return todos.filter((t) => t.completed)
}, 'filteredTodos')

Now the loader only recomputes when navigating to/from the route, and the tab is managed separately. The filteredTodos computed reacts to both todos data and tab changes without refetching.

Move model creation to separate computed atoms that extend the route. The loader of a route is a simple computed using withAsyncData, so you can reimplement it by yourself easily.Create a todosResource for data fetching, and a filteredList that depends on it.

import { computed, withAsyncData, wrap } from '@reatom/core'
const todosRoute = reatomRoute({
path: 'todos',
search: z.object({
tab: z.enum(['all', 'open', 'closed']).optional(),
}),
}).extend((target) => {
// ✅ Todos data is fetched only when route matches, stable across tab changes
const todosResource = computed(async () => {
if (!target.match()) return []
return await wrap(fetch('/api/todos').then((r) => r.json()))
}, `${target.name}.todosResource`).extend(withAsyncData({ initState: [] }))
// ✅ Filtered list reacts to tab changes but uses stable todos data
const filteredList = computed(() => {
const todos = todosResource.data()
const tab = params.tab || 'all'
if (tab === 'all') return todos
if (tab === 'open') return todos.filter((t) => !t.completed)
return todos.filter((t) => t.completed)
}, `${target.name}.filteredList`)
return {
todosResource,
filteredList,
}
})

The todosResource only fetches when the route matches and remains stable across tab changes. The filteredList computed reacts to tab changes but uses the stable todosResource.data() without triggering refetches.

Instead of creating the model statically in extend, create it dynamically using memo inside the loader. This is the same pattern as Solution 2, but the model is created inside the loader. It may be useful if you have a temporal (for the route) state, which you want to clear when the route becomes inactive (the meaning of the factory pattern).

import { memo, computed, withAsyncData, wrap } from '@reatom/core'
const todosRoute = reatomRoute({
path: 'todos',
search: z.object({
tab: z.enum(['all', 'open', 'closed']).optional(),
}),
async loader(params) {
const model = memo(() => {
// ✅ Track the route match to recreate the model on route remount
todosRoute.match()
const todosResource = computed(async () => {
if (!todosRoute.match()) return []
return await wrap(fetch('/api/todos').then((r) => r.json()))
}, `${todosRoute.name}.todosResource`).extend(
withAsyncData({ initState: [] }),
)
// ✅ Create local states fro the current route visit lifetime
const search = atom('', `${todosRoute.name}.search`)
const filteredList = computed(() => {
let todos = todosResource.data()
const tab = params.tab || 'all'
if (tab === 'open') todos = todos.filter((t) => !t.completed)
else if (tab === 'closed') todos = todos.filter((t) => t.completed)
const searchState = search().toLowerCase()
return todos.filter((t) => t.title.toLowerCase().includes(searchState))
}, `${target.name}.filteredList`)
return {
search,
todosResource,
filteredList,
}
})
return model
},
})

The memo functions ensure that the whole model is only created once when the loader first runs. The model lifetime controlled only by atoms read inside memo (which is only match in this case).

When to Use Each Solution:

  • Solution 1 (Separate Search Params): Best when search parameters are truly independent UI state that shouldn’t affect your models (e.g., UI filters, view modes, sorting)
  • Solution 2 (Separate Model Atom): Best when you want the model lifecycle tied to route matching rather than parameter changes
  • Solution 3 (Memo): Best when you need fine-grained control over which parameters trigger model recreation, especially when some parameters should be stable

Summary of common errors and their solution

We know that any URL parameters, whether they are path parameters or search parameters, are all strings, so they are an input for parsing and validating these parameters through the Standard Schema library. Therefore, in order to satisfy the validation contract for any route parameters, input must always be compatible with a string, and then it can be converted to anything you want.

const route = reatomRoute({
path: 'users/:userId',
params: z.object({
userId: z.number(), // ❌ URL params are always strings!
}),
})

If you use Zod, then the easiest way to follow the contract is to do coerce which will make the input parameters of the unknown type

const route = reatomRoute({
path: 'users/:userId',
params: z.object({
userId: z.coerce.number(), // ✅ Correct: use type coercion or explicit transform from string to number
}),
})
  • Learn about Forms to build complex forms with validation
  • Explore Async Context to understand wrap() and async effects
  • Check out Persistence to save route state across sessions
  • Read about Testing to test your routes in isolation