Skip to content

Wildhoney/Wayfinder

Repository files navigation

react-wayfinder

build

Strongly-typed React router built on the Navigation API. No outlets, no nesting β€” just routes, loaders, and a URL builder.

react-wayfinder demo

Table of Contents

  1. Getting Started
  2. Navigation State
  3. Cancellation
  4. Caching
  5. View Transitions
  6. Router Modes
  7. Nested Routes

Getting Started

Install react-wayfinder using your preferred package manager:

yarn add react-wayfinder

Define your URL patterns in a central urls object so every route definition, router.url() call, and <Route> reference points to a single source of truth. Changing a pattern updates every call site at once:

export const urls = {
  home: "/",
  user: "/users/:id",
} as const;

Define your routes and render the router:

import { createRoot } from "react-dom/client";
import { route, Router } from "react-wayfinder";
import type { Routes } from "react-wayfinder";

const routes = [
  route({
    url: urls.home,
    component() {
      return <h1>Home</h1>;
    },
  }),
  route({
    url: urls.user,
    async loader({ params, signal }) {
      return fetchUser(params.id, { signal });
    },
    component({ status, params, data, error }) {
      switch (status) {
        case "loading": return <p>Loading&hellip;</p>;
        case "error":   return <p>{error.message}</p>;
        case "ready":   return <User id={params.id} name={data.name} />;
      }
    },
  }),
] satisfies Routes;

createRoot(document.getElementById("root")!).render(
  <Router routes={routes} />
);

Routes without a loader receive params and url. Routes with a loader receive a discriminated union β€” narrow data via status ("loading", "ready", "error"). Use "*" as a catch-all for unmatched routes.

Navigation State

Wrap any navigable element in <Route> to get href, active, pending, and handler. For <a> tags, use href β€” the Navigation API intercepts the click natively. For <button> elements, attach handler as onClick to navigate via navigation.navigate(). Every <Route> whose href matches the navigation destination shows pending: true while a loader is running:

import { Route, useRouter } from "react-wayfinder";

const router = useRouter();

<Route href={router.url(urls.user, { id: 1 })}>
  {route => (
    <a href={route.href}>
      User 1 {route.pending ? <Spinner /> : null}
    </a>
  )}
</Route>

<Route href={router.url(urls.user, { id: 1 })}>
  {route => (
    <button onClick={route.handler}>
      User 1 {route.pending ? <Spinner /> : null}
    </button>
  )}
</Route>
Property Type Description
href string The resolved URL string β€” use as href on <a> tags
active boolean true if this href matches the currently rendered route
pending boolean true while navigating to this href
handler (event?) => void Attach as onClick on <button> elements β€” navigates via the Navigation API

Cancellation

Every loader receives an AbortSignal via signal. The signal is aborted when:

  • The user presses Escape during a pending navigation
  • A new navigation supersedes the current one (clicking User 2 while User 1 is loading)
async loader({ params, signal }) {
  const response = await fetch(`/api/users/${params.id}`, { signal });
  return response.json();
}

When cancelled, the router restores the previous route and URL β€” no stale state. Escape only fires when a loader is in-flight; pressing Escape after navigation completes does nothing.

Caching

Every loader receives cache β€” the previously loaded data for that route, or undefined on first visit. The router always calls the loader; you decide the caching strategy:

async loader({ params, signal, cache }) {
  if (cache) return cache;
  const response = await fetch(`/api/users/${params.id}`, { signal });
  return response.json();
}

Previously visited routes are preserved in the DOM using React <Activity> β€” their component state, scroll position, and form inputs survive navigation. The example app's /feed route demonstrates this: scroll down to load more items via the infinite loader, navigate away, then come back β€” your scroll position and every loaded item are still there.

View Transitions

The router automatically wraps route swaps in document.startViewTransition() when the browser supports it. It sets data-direction="forward" or data-direction="back" on <html> so you can style direction-aware animations with CSS:

:root {
  --transition-duration: 250ms;
}

[data-direction="forward"]::view-transition-old(root) {
  animation: slide-out-left var(--transition-duration) ease-in-out;
}
[data-direction="forward"]::view-transition-new(root) {
  animation: slide-in-from-right var(--transition-duration) ease-in-out;
}

[data-direction="back"]::view-transition-old(root) {
  animation: slide-out-right var(--transition-duration) ease-in-out;
}
[data-direction="back"]::view-transition-new(root) {
  animation: slide-in-from-left var(--transition-duration) ease-in-out;
}

Direction is detected via the Navigation API β€” "back" when traversing to a lower history index, "forward" otherwise. Cancel clears the data-direction attribute to prevent unwanted animations.

Router Modes

The mode prop controls how the router transitions between routes with loaders:

<Router routes={routes} mode="deferred" />
Mode Behaviour
"deferred" (default) Keeps the previous page on screen while the loader runs. Inline spinners via <Route> show on the clicked element.
"immediate" Switches to the new route immediately with status: "loading" so you can render skeletons. Escape restores the previous route from the preserved <Activity>.

When deploying to a sub-path (e.g. https://example.com/my-app/), pass base so the router strips the prefix before matching β€” route patterns stay root-relative. With Vite, use import.meta.env.BASE_URL to keep it in sync with your config:

<Router routes={routes} base={import.meta.env.BASE_URL} />

Use useRouter() for navigation status and the base-aware URL builder:

const router = useRouter();

router.status
router.url(urls.user, { id: 42 })

Nested Routes

<Route> can be nested freely. A top-level navigation bar uses <Route> for each link, and the page it renders can nest its own <Route> instances for sub-navigation. Each <Route> independently tracks active and pending for its own href:

function Contact() {
  const router = useRouter();

  return (
    <>
      <nav>
        <Route href={router.url(urls.home)}>
          {route => <a href={route.href} className={route.active ? "active" : ""}>Home</a>}
        </Route>
        <Route href={router.url(urls.contact, { method: "email" })} active={path => path.startsWith("/contact")}>
          {route => <a href={route.href} className={route.active ? "active" : ""}>Contact</a>}
        </Route>
      </nav>

      <nav>
        <Route href={router.url(urls.contact, { method: "email" })}>
          {route => <a href={route.href} className={route.active ? "active" : ""}>Email</a>}
        </Route>
        <Route href={router.url(urls.contact, { method: "telephone" })}>
          {route => <a href={route.href} className={route.active ? "active" : ""}>Telephone</a>}
        </Route>
      </nav>
    </>
  );
}

The top-level "Contact" link uses a custom active predicate so it stays highlighted regardless of which sub-tab is selected. Each nested <Route> uses the default exact match.

About

πŸ•οΈ Strongly-typed React router built on the Navigation API. No outlets, no nesting β€” just routes, loaders, and a URL builder.

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages