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

react-wayfinder

v0.0.4

Published

React hooks for the Navigation API

Readme

build

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

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: "idle" | "navigating"
// router.url(urls.user, { id: 42 }): "/users/42"

Nested Routes

<Route> can be nested freely. A top-level navigation bar wraps each link in a <Route>, 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 Navigation() {
  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>
  );
}

The contact page nests a second layer of <Route> components for its tab bar. Both layers coexist — the top-level "Contact" link shows active via its custom predicate, while the nested tabs each track their own active and pending state:

const methods = ["email", "telephone", "postal"] as const;

function Contact({ method }: { method: string }) {
  const router = useRouter();

  return (
    <>
      <Navigation />
      <nav>
        {methods.map(value => (
          <Route key={value} href={router.url(urls.contact, { method: value })}>
            {route => (
              <a href={route.href} className={route.active ? "active" : ""}>
                {value}
                {route.pending ? <Spinner /> : null}
              </a>
            )}
          </Route>
        ))}
      </nav>
    </>
  );
}

The top-level navigation uses a custom active predicate (path => path.startsWith("/contact")) so the "Contact" link stays highlighted regardless of which sub-tab is selected. Each nested <Route> uses the default exact match, so only the current tab is active.