jotai-iten
v0.5.0
Published
Jotai adapter for iten — ultralight in-memory router for embedded JS apps
Maintainers
Readme
jotai-iten
Typed in-memory routing for React apps that already use Jotai.
jotai-iten is built for embedded UI surfaces: Figma plugins, VS Code webviews, browser extension panels, Electron sidebars, iframe widgets, modal stacks, and internal tools where a browser URL is absent or secondary. It gives you typed route state, loader orchestration, guards, pending state, history, and scoped atoms without adopting a URL-first router.
Install
npm install jotai-iten jotai reactPeer dependencies:
jotai@^2react@^18.3 || ^19
Optional:
@tanstack/react-query@^5if your loaders callensureQueryDatazod@^4if you importjotai-iten/zod
iten-core is installed transitively by jotai-iten. Install it directly only if you are using the framework-agnostic core without the Jotai adapter.
Entry points:
jotai-itenfor the full router with components, hooks, atoms, and utilitiesjotai-iten/headlessfor hooks and atoms without component helpersjotai-iten/zodfor optional schema-backed route factories and unknown-input parsingjotai-iten/urlfor optional URL synchronization around explicit parse/format hooksjotai-iten/utilsfor route factories, guards, and exhaustive matching
Mental Model
A route is a discriminated union:
const routes = defineRoutes({
home: route(),
detail: route<{ id: string }>(),
settings: route(),
})
type Routes = InferRoutes<typeof routes>Navigation is an async pipeline:
navigate({ target })
-> beforeLoad guard
-> optional loader
-> commit target routeThe current route stays mounted until the target route is ready. Loader failures keep the previous route and expose a retryable error.
Quick Start
// router.ts
import {
createRouter,
defineRoutes,
route,
} from 'jotai-iten'
const routes = defineRoutes({
home: route(),
detail: route<{ id: string }>(),
settings: route(),
})
export const router = createRouter({
routes,
initial: { name: 'home' },
})
export const {
Route,
Switch,
Link,
Navigate,
useNavigate,
useRoute,
useCurrentRoute,
} = router// App.tsx
import { Navigate, Route, Switch, router } from './router'
export function App() {
return (
<Switch>
<Route name="home">{() => <HomeView />}</Route>
<Route name="detail">{({ id }) => <DetailView id={id} />}</Route>
<Route name="settings">{() => <SettingsView />}</Route>
<Navigate to={router.to({ name: 'home' })} />
</Switch>
)
}No router provider is required. Use the normal Jotai provider only if your app already uses a custom store.
Headless Router
Use jotai-iten/headless when you want atoms and hooks but do not need Route, Switch, Link, or Navigate. This keeps component helpers out of hook-only bundles.
import {
createHeadlessRouter,
defineRoutes,
route,
} from 'jotai-iten/headless'
const routes = defineRoutes({
home: route(),
detail: route<{ id: string }>(),
})
export const router = createHeadlessRouter({
routes,
initial: { name: 'home' },
})
export const { atoms, useNavigate, useRoute, useCurrentRoute } = routercreateRouter is built on the same headless layer, so behavior and types stay consistent across both entry points.
Creating Routes
Use defineRoutes with createRouter or createHeadlessRouter for inferred route maps. The returned router exposes router.to(...) for typed route targets.
router.to({ name: 'home' })
router.to({ name: 'detail', params: { id: '42' } })Use InferRoutes plus createRoute when route types or factories need to live independently from a router instance.
type Routes = InferRoutes<typeof routes>
const toRoute = createRoute(routes)
toRoute({ name: 'home' })
toRoute({ name: 'detail', params: { id: '42' } })For no-param routes, call route() without a type argument. Avoid route<Record<string, never>>() because Record<string, never> conflicts with the name discriminant in intersection types.
Loaders
Loaders run before the target route commits.
export const router = createRouter({
routes,
initial: { name: 'home' },
queryClient,
routeConfig: {
detail: {
loader: async ({ params, queryClient }) => {
await queryClient.ensureQueryData(detailQuery(params.id))
},
loaderDeps: ({ params }) => params.id,
staleTime: 30_000,
},
},
})Behavior:
- The current route remains active while the loader runs.
pendingRoutetracks the target route immediately.useRouteLoading({ name: 'detail' })is true while that route is pending.- If the loader throws, the previous route stays mounted and
useRouterError()returns{ error, retry }. retry()reruns the full pipeline, including guards.
The queryClient only needs an ensureQueryData method. TanStack Query works, but it is not required by the router.
Guards
beforeLoad can redirect before a loader runs. In jotai-iten, guards receive a Jotai Getter, so they can read atoms.
import { authAtom } from './atoms'
const router = createRouter({
routes,
initial: { name: 'home' },
routeConfig: {
detail: {
beforeLoad: ({ get, to }) => {
if (!get(authAtom).userId) return to({ name: 'home' })
},
loader: async ({ params, queryClient }) => {
await queryClient.ensureQueryData(detailQuery(params.id))
},
},
},
})Redirect loops are capped by iten-core and become retryable errors instead of infinite recursion.
Context
Use context to compute shared values once per navigation. It can be a static object or a function that reads atoms.
const router = createRouter({
routes,
initial: { name: 'home' },
context: ({ get }) => ({ userId: get(authAtom).userId }),
routeConfig: {
detail: {
loader: async ({ params, queryClient, context }) => {
await queryClient.ensureQueryData(detailQuery(params.id, context.userId))
},
},
},
})Components
<Route>
Renders when its route is active.
<Route name="detail">
{({ id }) => <DetailView id={id} />}
</Route>You can pass a component instead of a render prop:
<Route name="detail" component={DetailView} />Pending and error states are route-local:
<Route
name="detail"
pendingComponent={DetailSkeleton}
errorComponent={({ error, retry }) => (
<ErrorBanner error={error} onRetry={retry} />
)}
>
{({ id }) => <DetailView id={id} />}
</Route><Switch>
Renders the first matching child. Use <Navigate> as a fallback.
<Switch>
<Route name="home">{() => <HomeView />}</Route>
<Route name="detail">{({ id }) => <DetailView id={id} />}</Route>
<Navigate to={router.to({ name: 'home' })} />
</Switch><Link>
Typed navigation with route-specific loading state.
<Link to={router.to({ name: 'detail', params: { id: item.id } })}>
{({ isLoading }) => (isLoading ? 'Loading...' : 'Open')}
</Link><Navigate>
Redirects on mount.
{!isAuthenticated && <Navigate to={router.to({ name: 'home' })} />}Hooks
| Hook | Returns | Use for |
|---|---|---|
| useCurrentRoute() | RouteUnion<M> \| null | Current committed route |
| useRoute({ name }) | { isActive, params } | Active checks with narrowed params |
| useNavigate() | ({ target, options }) => Promise<void> | Programmatic navigation |
| useGoBack() | () => Promise<void> | Back navigation without rerunning loaders |
| useIsNavigating() | boolean | Global loader/pending indicator |
| useCanGoBack() | boolean | History availability |
| useRouteLoading({ name }) | boolean | Route-specific pending indicator |
| useRouterError() | RouterError \| null | Last retryable navigation error |
Example:
function Header() {
const navigate = useNavigate()
const { isActive } = useRoute({ name: 'settings' })
const isLoading = useRouteLoading({ name: 'settings' })
return (
<button
type="button"
aria-current={isActive ? 'page' : undefined}
onClick={() => void navigate({ target: router.to({ name: 'settings' }) })}
>
{isLoading ? 'Loading...' : 'Settings'}
</button>
)
}Type Utilities and Guards
These are also available from jotai-iten/utils for utility-only imports.
import {
createRoute,
defineRoutes,
isRoute,
isRouteName,
matchRoute,
type InferRoutes,
route,
} from 'jotai-iten/utils'defineRoutes
Defines the runtime route-name object and derives the compile-time route map.
const routes = defineRoutes({
list: route(),
detail: route<{ id: string }>(),
})
type Routes = InferRoutes<typeof routes>
const toRoute = createRoute(routes)isRoute
Narrows unknown values by discriminant.
function readDetailId(value: unknown) {
const candidate = { value, name: 'detail' as const }
if (isRoute<Routes, 'detail'>(candidate)) {
return candidate.value.id
}
}isRouteName
Useful when decoding host messages or URL-like state.
const names = ['home', 'detail', 'settings'] as const
const candidateName = { names, value: maybeName }
if (isRouteName(candidateName)) {
candidateName.value // 'home' | 'detail' | 'settings'
}matchRoute
Exhaustive branching over the current route.
const label = matchRoute<Routes, string>({
route: currentRoute,
matcher: {
home: () => 'Home',
detail: ({ params }) => `Detail ${params.id}`,
settings: () => 'Settings',
},
})Multiple Routers
Each createRouter call creates isolated atoms and components. This is useful for modal stacks or embedded subpanels.
const modals = defineRoutes({
confirm: route<{ message: string; onConfirm: () => void }>(),
imagePicker: route<{ onSelect: (uri: string) => void }>(),
})
export const modalRouter = createRouter({
routes: modals,
initial: null,
})Timing
Use pendingMs and pendingMinMs to avoid flicker.
routeConfig: {
detail: {
pendingMs: 200,
pendingMinMs: 100,
loader: async ({ params, queryClient }) => {
await queryClient.ensureQueryData(detailQuery(params.id))
},
},
}pendingMs delays the visible loading state. pendingMinMs keeps it visible long enough to avoid a flash once shown.
Advanced Atoms
The router exposes raw atoms for advanced Jotai composition.
const { atoms } = router
// atoms.state
// atoms.navigate
// atoms.goBackMost apps should prefer hooks/components. Atoms are useful when composing with existing Jotai state modules.
URL Sync
jotai-iten is in-memory by default. Import jotai-iten/url only when the host surface needs URL synchronization. The adapter uses explicit parse and format functions instead of path-pattern route definitions, so the default router entry stays small.
import { createUrlSync } from 'jotai-iten/url'Hydrate from the URL when your app starts, then subscribe if the URL should keep following router state:
const urlSync = createUrlSync<Routes>({
router,
parse: ({ url }) => {
const id = url.searchParams.get('id')
return id ? router.to({ name: 'detail', params: { id } }) : router.to({ name: 'home' })
},
format: ({ route, url }) => {
const next = new URL(url)
next.searchParams.set('route', String(route.name))
if (route.name === 'detail') {
next.searchParams.set('id', route.id)
} else {
next.searchParams.delete('id')
}
return next
},
})
await urlSync.hydrate()
const stopUrlSync = urlSync.start({ mode: 'replace' })Pass custom getUrl, writeUrl, and subscribeUrl functions for tests, embedded hosts, iframe bridges, extension panels, or any environment where the browser History API is not the source of truth.
Zod Runtime Validation
Zod is a good fit for validating external input: host messages, deep links, persisted state, or URL sync. It is an optional peer used only by jotai-iten/zod, so the default router entry stays small.
Define schemas once, derive the route union from them, and use a validated route factory in app code:
import { createRouter } from 'jotai-iten'
import {
createZodRoute,
defineZodRoutes,
parseZodRoute,
zodNoParams,
type ZodRouteMap,
} from 'jotai-iten/zod'
import { z } from 'zod'
const schemas = defineZodRoutes({
home: zodNoParams(),
detail: z.object({
id: z.string().min(1),
tab: z.enum(['summary', 'activity']).default('summary'),
}),
})
type Routes = ZodRouteMap<typeof schemas>
const zodRoute = createZodRoute(schemas)
const router = createRouter<Routes, unknown>({
initial: zodRoute({ name: 'home' }),
})
zodRoute({ name: 'detail', input: { id: '42' } })
zodRoute({ name: 'detail', input: { id: '42', tab: 'activity' } })Use parseZodRoute when the input is unknown:
const parsed = parseZodRoute({ schemas, value: hostMessage })
if (parsed.success) {
await navigate({ target: parsed.route })
}The parser rejects unknown route names, invalid params, and params that try to define their own name field.
For an end-to-end example, see examples/zod.
Troubleshooting
My no-param route type does not work
Use route() for no-param routes, not route<Record<string, never>>().
const routes = defineRoutes({
home: route(),
})My loader does not run again
Check loaderDeps and staleTime. If staleTime has not expired for the same dependency key, the loader is skipped.
I need direct imports without components
Use:
import { createRoute, matchRoute } from 'jotai-iten/utils'Should I use React Router or TanStack Router instead?
Use a URL router when URLs, nested route trees, SSR, route files, or search-param state are central to the app. Use jotai-iten when routing is local state and you want small typed primitives.
License
MIT
