pyreon

Router setup

The pattern

Define routes at module scope, wrap the app in a provider, mount <RouterView />:

import { createRouter, RouterProvider, RouterView, RouterLink } from '@pyreon/router'

const router = createRouter({
  routes: [
    { path: '/', name: 'home', component: Home },
    {
      path: '/user/:id',
      name: 'user',
      component: UserPage,
      loader: async ({ params }) => api.fetchUser(params.id),
      loaderKey: ({ params }) => `user-${params.id}`,  // for cache identity
    },
    { path: '/admin', name: 'admin', component: Admin, guard: adminOnly },
  ],
})

function App() {
  return (
    <RouterProvider router={router}>
      <nav>
        <RouterLink name="home">Home</RouterLink>
        <RouterLink name="user" params={{ id: '42' }}>User 42</RouterLink>
      </nav>
      <RouterView />
    </RouterProvider>
  )
}

For typed named navigation, constrain the Router generic:

const router = createRouter<'home' | 'user' | 'admin'>({ routes })

router.push({ name: 'home' })        // ok
router.push({ name: 'typo' })        // TS error at compile time

Key APIs

  • router.push({ name, params?, search? }) — navigate forward (history entry added)

  • router.replace(...) — navigate without adding a history entry

  • useRoute() — signal that reads the current resolved route

  • useIsActive(path, exact?)Signal<boolean> for active-link styling

  • useTypedSearchParams({ page: 'number' }) — coerced + typed query params

  • router.invalidateLoader(key?) — clear loader cache entries

await router.push(...) resolves AFTER the route's view-transition callback commits, so the next line can read the new route immediately.

Why

Pyreon's router is context-based + signal-driven. Routes are resolved into Signal<ResolvedRoute> so RouterView re-renders only when the route changes, not when unrelated state changes. Typed routes catch dead navigation calls at compile time — a rename in the routes array surfaces as a TypeScript error across every router.push / <RouterLink name> site.

Anti-pattern

// BROKEN — mutating window.location directly bypasses the router
window.location.pathname = '/user/42'  // full page reload, loses SPA state

// Correct:
router.push({ name: 'user', params: { id: '42' } })
// BROKEN — useRoute() called at module scope
const route = useRoute()   // hooks require an active component setup context

// Correct: call inside a component body
function UserPage() {
  const route = useRoute()
  return <div>{() => route().params.id}</div>
}
// BROKEN — router.push inside the render body triggers infinite loops
function Redirect() {
  router.push({ name: 'home' })   // renders, pushes, renders, pushes…
  return null
}

// Correct: push from onMount or an event handler (detector:
// no-imperative-navigate-in-render catches this in the lint rule)
function Redirect() {
  onMount(() => router.push({ name: 'home' }))
  return null
}
  • Reference API: createRouter, useRoute, RouterView, RouterLinkget_api({ package: "router", symbol: "..." })

  • Lint rule: pyreon/no-imperative-navigate-in-render

  • Anti-pattern: "const handleClick = () => router.push(…); handleClick() in render body"

Router setup