npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

jotai-iten

v0.5.0

Published

Jotai adapter for iten — ultralight in-memory router for embedded JS apps

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 react

Peer dependencies:

  • jotai@^2
  • react@^18.3 || ^19

Optional:

  • @tanstack/react-query@^5 if your loaders call ensureQueryData
  • zod@^4 if you import jotai-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-iten for the full router with components, hooks, atoms, and utilities
  • jotai-iten/headless for hooks and atoms without component helpers
  • jotai-iten/zod for optional schema-backed route factories and unknown-input parsing
  • jotai-iten/url for optional URL synchronization around explicit parse/format hooks
  • jotai-iten/utils for 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 route

The 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 } = router

createRouter 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.
  • pendingRoute tracks 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.goBack

Most 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