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.
Quick Start
Section titled “Quick Start”Here’s a minimal example to get you started:
import { reatomRoute } from '@reatom/core'
export const homeRoute = reatomRoute('')export const aboutRoute = reatomRoute('about')
// Navigate programmaticallyhomeRoute.go()aboutRoute.go()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.
Core Concepts
Section titled “Core Concepts”Route Atoms
Section titled “Route Atoms”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.
Exact vs Partial Matching
Section titled “Exact vs Partial Matching”Routes can match partially (prefix) or exactly:
const usersRoute = reatomRoute('users')const userRoute = reatomRoute('users/:userId')
// At URL: /users/123usersRoute() // { } - matches partiallyusersRoute.exact() // false - not an exact matchuserRoute() // { userId: '123' }userRoute.exact() // true - exact matchUse .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} />}Navigation
Section titled “Navigation”Navigate using the .go() method:
// Navigate with parametersuserRoute.go({ userId: '123' })
// Navigate without parametershomeRoute.go()
// Navigate with type safety - TypeScript error if wrong paramsuserRoute.go({ userId: 123 }) // ❌ Error: userId must be stringYou can also use urlAtom directly for raw URL changes:
import { urlAtom } from '@reatom/core'
// Navigate to any URLurlAtom.go('/some/path')urlAtom.go('/users/123?tab=posts')
// Read current URLconst { pathname, search, hash } = urlAtom()Route Parameters
Section titled “Route Parameters”Define dynamic segments with :paramName:
// Required parameterconst userRoute = reatomRoute('users/:userId')
// Optional parameter (note the ?)const postRoute = reatomRoute('posts/:postId?')
postRoute.go({}) // → /postspostRoute.go({ postId: '42' }) // → /posts/42Building URLs
Section titled “Building URLs”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)Nested Routes
Section titled “Nested Routes”Build route hierarchies by chaining .reatomRoute():
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/editdashboardRoute() // { }usersRoute() // { }userRoute() // { userId: '123' }userEditRoute() // { userId: '123' }
dashboardRoute.exact() // falseusersRoute.exact() // falseuserRoute.exact() // falseuserEditRoute.exact() // trueNested routes inherit parent parameters:
// Navigate to /dashboard/users/123/edituserEditRoute.go({ userId: '123' })
// All parent routes automatically matchdashboardRoute() // { }usersRoute() // { }userRoute() // { userId: '123' }Component Composition Pattern
Section titled “Component Composition Pattern”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:
renderoption - Each route can define arenderfunction that returns your component (string, object, or any type)outlet()computed - Returns an array of all active child routes’ rendered components- Automatic rendering - When the URL matches a route, its
render()is called and added to parent’soutlet() - Memory management - Components are automatically cleaned up when routes become inactive
- Layout routes - Routes can omit the
pathto 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
Custom types for your framework
Section titled “Custom types for your framework”The RouteChild type can be redeclared for your framework:
// For React/Preactdeclare module '@reatom/core' { interface RouteChild extends JSX.Element {}}
// For Vuedeclare module '@reatom/core' { interface RouteChild extends VNode {}}
// For Litdeclare module '@reatom/core' { interface RouteChild extends TemplateResult {}}Here is how it would look like in React:
IMPORTANT NOTE: you cannot use hooks inside
renderfunction, 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>})Recursive type errors
Section titled “Recursive type errors”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> ) },})Path Parameters
Section titled “Path Parameters”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 numberuserRoute.go({ userId: '123' }) // ✅ ValiduserRoute.go({ userId: 'abc' }) // ❌ Throws validation error
// At URL: /users/123const params = userRoute()params.userId // Type: number, Value: 123If validation fails, the route returns null:
// At URL: /users/invaliduserRoute() // null (validation failed)Search Parameters
Section titled “Search Parameters”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 paramssearchRoute.go({ q: 'reatom', page: 2, sort: 'desc' })// URL: /search?q=reatom&page=2&sort=desc
// At URL: /search?q=reatomconst params = searchRoute()params.q // 'reatom'params.page // 1 (default applied)params.sort // undefinedSearch-Only Routes
Section titled “Search-Only Routes”Routes can have only search parameters with no path. These are useful for global overlays like modals or filters.
Standalone Search-Only Routes
Section titled “Standalone Search-Only Routes”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/123dialogRoute.go({ dialog: 'login' })// URL: /profile/123?dialog=login (pathname preserved)
// Navigate elsewhereurlAtom.go('/settings')// dialogRoute() still works: reads ?dialog param from any URL
// Close dialogdialogRoute.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')Nested Search-Only Routes
Section titled “Nested Search-Only Routes”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 /homesettingsDialogRoute.go({ dialog: 'export' })// URL: /settings?dialog=export (navigates to parent path)
// User is at /settings/profilesettingsDialogRoute.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
Avoiding Parameter Collisions
Section titled “Avoiding Parameter Collisions”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=456badRoute() // ❌ 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(), }),})Data Loading with Loaders
Section titled “Data Loading with Loaders”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')Loader State Properties
Section titled “Loader State Properties”Since route loader it’s an async computed, you can access the same properties that available with withAsyncData extension:
loader.ready()- Boolean atom that istruewhen data has loaded successfullyloader.data()- Atom with the loaded data (throws if not ready)loader.error()- Atom with error if loading failed,nullotherwiseloader.retry()- Action to trigger a retry for loader, that will rerun the loader function
Default Loader
Section titled “Default Loader”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 paramsconst params = await wrap(searchRoute.loader())params.q // stringparams.page // numberLoader with Nested Routes
Section titled “Loader with Nested Routes”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 },})Automatic Abort on Navigation
Section titled “Automatic Abort on Navigation”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 awaysomeOtherRoute.go({})// ✅ Loader fetch and effects are automatically abortedBuilding Page Components
Section titled “Building Page Components”Basic Page Component
Section titled “Basic Page Component”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')Page with Loader
Section titled “Page with Loader”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')Main App Component
Section titled “Main App Component”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',)Advanced Patterns
Section titled “Advanced Patterns”Global Loading State
Section titled “Global Loading State”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.
The Computed Factory Pattern
Section titled “The Computed Factory Pattern”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.
The Problem
Section titled “The Problem”- Local state (
useState) has automatic cleanup but suffers from prop drilling - Global state is easy to share but requires manual memory management
The Solution
Section titled “The Solution”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')Automatic Memory Management
Section titled “Automatic Memory Management”When navigating from /users/123/edit to /users/456/edit:
- Loader executes with
{ userId: 456 } - New form is created for user 456
- Previous form for user 123 is automatically garbage collected
- 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
Complex State Factories
Section titled “Complex State Factories”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.
Solution 1: Separate Search Parameters
Section titled “Solution 1: Separate Search Parameters”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 paramconst 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 tabconst 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.
Solution 2: Separate Model Atom
Section titled “Solution 2: Separate Model Atom”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.
Solution 3: Memo Inside Loader
Section titled “Solution 3: Memo Inside Loader”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
Troubleshooting
Section titled “Troubleshooting”Summary of common errors and their solution
Validation errors
Section titled “Validation errors”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 }),})Next Steps
Section titled “Next Steps”- 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