iten-core
v0.4.2
Published
Ultralight in-memory router for embedded JS apps — framework-agnostic core
Maintainers
Readme
iten-core
Framework-agnostic state machine for typed in-memory routing. Zero dependencies. Pure TypeScript.
Most React apps should start with jotai-iten. Use iten-core when you want the low-level router directly or you are building an adapter for another state library.
Install
npm install iten-coreiten-core does not depend on React, Jotai, TanStack Query, or Zod. Loaders can use any query client-like object with an ensureQueryData method.
Concepts
A route is a discriminated union where name identifies the screen and the remaining fields are that screen's params. Define routes as a runtime object, then derive the TypeScript route map from it.
import { defineRoutes, type InferRoutes, route } from 'iten-core'
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 remains committed until the target route is ready. Loader failures keep the previous route and expose a retryable router error.
Quick Start
import {
createItenCore,
createRoute,
defineRoutes,
type InferRoutes,
route,
} from 'iten-core'
const routes = defineRoutes({
home: route(),
detail: route<{ id: string }>(),
})
type Routes = InferRoutes<typeof routes>
const toRoute = createRoute(routes)
const core = createItenCore<Routes, unknown>({
initial: toRoute({ name: 'home' }),
routeConfig: {
detail: {
loader: async ({ params, queryClient }) => {
await queryClient.ensureQueryData(detailQuery(params.id))
},
},
},
})
const unsubscribe = core.subscribe({
listener: (state) => {
console.log(state.route, state.isNavigating)
},
})
await core.navigate({ target: toRoute({ name: 'detail', params: { id: '42' } }) })
const { route: currentRoute, history, canGoBack } = core.getState()
core.goBack()
unsubscribe()
core.dispose()Configuration
type CoreRouterConfig<M, Ctx> = {
initial: RouteUnion<M> | null
routeConfig?: { [K in keyof M]?: RouteConfig<M, K, Ctx> }
maxHistoryLength?: number
queryClient?: QueryClientLike | null
getCtx?: () => Ctx
}| Option | Default | Description |
|---|---:|---|
| initial | Required | Initial committed route. Use null for routers that begin closed, such as modal routers. |
| routeConfig | {} | Per-route loaders, guards, lifecycle hooks, and pending timing. |
| maxHistoryLength | 50 | Maximum number of previous routes retained for goBack. |
| queryClient | null | Passed to loaders. Only ensureQueryData is required. |
| getCtx | undefined | Computes navigation context for guards, loaders, and hooks. |
Core API
const core = createItenCore<Routes, Ctx>(config)| Method | Description |
|---|---|
| navigate({ target, options }) | Runs guards and loaders, then commits the target route. Returns a promise. |
| goBack() | Pops history and commits the previous route without rerunning its loader. |
| subscribe(listener) | Subscribes to state changes and returns an unsubscribe function. |
| getState() | Returns the current router state snapshot. |
| dispose() | Marks the instance disposed, clears listeners, and prevents later async commits. |
Router State
type RouterState<M> = {
route: RouteUnion<M> | null
pendingRoute: RouteUnion<M> | null
isNavigating: boolean
history: RouteUnion<M>[]
canGoBack: boolean
error: RouterError | null
}| Field | Meaning |
|---|---|
| route | Last committed route. |
| pendingRoute | Target route while a navigation is in flight. |
| isNavigating | true while pending state should be visible. |
| history | Previous committed routes, newest last. |
| canGoBack | Whether goBack() can commit a previous route. |
| error | Retryable error from the latest failed navigation, or null. |
Routes
type RouteConfig<M, K, Ctx> = {
loader?: (input: { params: M[K]; queryClient: QueryClientLike; context: Ctx }) => Promise<void>
loaderDeps?: (input: { params: M[K] }) => unknown
staleTime?: number
beforeLoad?: (input: { params: M[K]; context: Ctx }) => RouteUnion<M> | undefined
pendingMs?: number
pendingMinMs?: number
onEnter?: (input: { params: M[K]; context: Ctx }) => void
onLeave?: (input: { params: M[K]; context: Ctx }) => void
}Loaders
Loaders run before the target route commits.
const core = createItenCore<Routes, unknown>({
initial: toRoute({ 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.isNavigatingbecomes visible according topendingMsandpendingMinMs.- If the loader throws, the previous route stays committed and
error.retry()reruns the pipeline. loaderDepsandstaleTimecan skip repeat loads for the same dependency key.
Guards
beforeLoad can redirect before the loader runs.
const core = createItenCore<Routes, { userId: string | null }>({
initial: toRoute({ name: 'home' }),
getCtx: () => ({ userId: auth.userId }),
routeConfig: {
detail: {
beforeLoad: ({ context }) => {
if (!context.userId) return toRoute({ name: 'home' })
},
},
},
})Redirect loops are capped and surfaced as retryable router errors instead of recurring indefinitely.
Timing
Use pendingMs and pendingMinMs to avoid loading-state flicker.
routeConfig: {
detail: {
pendingMs: 200,
pendingMinMs: 100,
loader: async ({ params, queryClient }) => {
await queryClient.ensureQueryData(detailQuery(params.id))
},
},
}pendingMs delays visible pending state. pendingMinMs keeps it visible long enough once shown.
Lifecycle Hooks
onEnter runs for the target route before it commits after a successful loader navigation. onLeave is available on route config and currently runs for skip-loader commits such as goBack().
routeConfig: {
detail: {
onEnter: ({ params }) => analytics.track('detail_opened', { id: params.id }),
onLeave: ({ params }) => analytics.track('detail_closed', { id: params.id }),
},
}Type Utilities and Guards
These utilities are also available from iten-core/utils for utility-only imports.
import {
createRoute,
defineRouteConfig,
defineRoutes,
isRoute,
isRouteName,
matchRoute,
type InferRoutes,
route,
} from 'iten-core/utils'createRoute
Creates routes with route-specific params.
const routes = defineRoutes({
home: route(),
detail: route<{ id: string }>(),
})
type Routes = InferRoutes<typeof routes>
const toRoute = createRoute(routes)
toRoute({ name: 'home' })
toRoute({ name: 'detail', params: { id: '42' } })
// Type error: detail requires id
toRoute({ name: 'detail' })defineRouteConfig
Preserves route-specific inference when authoring route config separately.
const routeConfig = defineRouteConfig<Routes, unknown>({
detail: {
loader: async ({ params }) => {
params.id // string
},
},
})isRoute and isRouteName
Use guards at runtime boundaries such as host messages, persisted state, or URL sync.
const candidate = { value: message, name: 'detail' as const }
if (isRoute<Routes, 'detail'>(candidate)) {
candidate.value.id // string
}
const names = ['home', 'detail', 'settings'] as const
const candidateName = { names, value: maybeName }
if (isRouteName(candidateName)) {
candidateName.value // 'home' | 'detail' | 'settings'
}matchRoute
Exhaustive branching over a route union.
const title = matchRoute<Routes, string>({
route: currentRoute,
matcher: {
home: () => 'Home',
detail: ({ params }) => `Detail ${params.id}`,
settings: () => 'Settings',
},
})Building an Adapter
Adapters should keep iten-core as the source of truth and mirror state into their host state library.
const core = createItenCore<M, Ctx>(config)
let snapshot = core.getState()
const unsubscribe = core.subscribe({
listener: (next) => {
snapshot = next
notifySubscribers()
},
})Recommended adapter shape:
- Create one core instance per router instance.
- Expose framework-native hooks, stores, atoms, or signals that derive from
core.getState(). - Delegate navigation to
core.navigateandcore.goBack. - Dispose the core when the adapter instance is no longer reachable.
- Re-export
iten-core/utilsso users can share route factories and guards without importing component code. - Use
iten-core/urlfor optional URL synchronization instead of mixing URL concerns into adapter state.
jotai-iten is the reference adapter.
URL Sync
iten-core is in-memory by default. Import iten-core/url only when a surface needs URL synchronization. The adapter uses explicit parse and format functions instead of path-pattern route definitions.
import { createUrlSync } from 'iten-core/url'Hydrate from the current URL explicitly:
const urlSync = createUrlSync<Routes>({
router: core,
parse: ({ url }) => {
const id = url.searchParams.get('id')
return id ? toRoute({ name: 'detail', params: { id } }) : toRoute({ 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' })Use custom getUrl, writeUrl, and subscribeUrl functions for non-browser hosts, tests, iframes, extension panels, or host-owned navigation.
Zod and Runtime Validation
Zod is useful for validating external input before it reaches the router. It is intentionally not a dependency of iten-core because route params are already typed inside trusted TypeScript code and many apps do not need runtime schemas in the navigation hot path.
const detailMessage = z.object({
name: z.literal('detail'),
id: z.string(),
})
const parsed = detailMessage.safeParse(hostMessage)
if (parsed.success) {
await core.navigate({ target: parsed.data })
}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 the same dependency key is still fresh, the loader is skipped.
I only need type utilities
Use the utility subpath:
import { createRoute, matchRoute } from 'iten-core/utils'Should this be a URL router?
Use a URL router when paths, search params, SSR, nested route trees, or route files are core product concerns. Use iten-core when routing is local state and you want a small typed state machine.
License
MIT
