react-wayfinder
v0.1.4
Published
React hooks for the Navigation API
Maintainers
Readme
Strongly-typed React router built on the Navigation API. No outlets, no nesting — just routes, loaders, and a URL builder.
Table of Contents
- Getting Started
- Navigation State
- Programmatic Navigation
- Redirects
- Cancellation
- Caching
- View Transitions
- Router Modes
- Nested Routes
Getting Started
Install react-wayfinder using your preferred package manager:
yarn add react-wayfinderDefine 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,
match() {
return <h1>Home</h1>;
},
}),
route({
url: urls.user,
async loader({ params, signal }) {
return fetchUser(params.id, { signal });
},
match({ status, params, data, error }) {
switch (status) {
case "loading": return <p>Loading…</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, url, and router. Routes with a loader additionally receive a discriminated union — narrow data via status ("loading", "ready", "error"). Use "*" as a catch-all for unmatched routes. The router argument is the same handle returned by useRouter() — useful when you need type-safe URL building or programmatic navigation outside of a hook context.
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 |
Pass replace to <Route> to replace the current history entry instead of pushing a new one. This works for both <a> clicks and handler invocations:
<Route href={router.url(urls.login)} replace>
{route => <a href={route.href}>Sign in</a>}
</Route>Programmatic Navigation
useRouter() returns a navigate(href, options?) function for navigating outside of a <Route>. Pair it with the url() builder to keep URLs type-safe:
const router = useRouter();
router.navigate(router.url(urls.user, { id: 1 })); // push
router.navigate(router.url(urls.login), { replace: true }); // replaceThe same router handle is passed to every route's match and redirect callback — so you can navigate type-safely from places where hooks aren't available.
Redirects
A route with a redirect prop replaces the current history entry with the resolved target instead of rendering anything. Use it as a catch-all or to canonicalise an incomplete URL:
const routes = [
route({
url: urls.cat,
match({ params }) {
return <Viewer index={Number(params.index)} />;
},
}),
route({
url: "*",
redirect: ({ router }) => router.url(urls.cat, { index: 0 }),
}),
] satisfies Routes;redirect accepts either a string or a callback receiving { params, url, router }. The callback form gives you access to the type-safe router.url() builder so you don't have to hard-code paths. Redirects always replace the current history entry — the browser back button skips past the redirected-from URL.
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.
