Concise router for React apps
Features: Concise API · Incrementally adoptable route type safety with fallback typing · useState-like URL params management · Straightforward CSR/SSR · Middleware · Lazy routes · View transitions
Installation: npm i @t8/react-router
URL-based rendering with at(route, x, y) works similarly to conditional rendering with the ternary operator atRoute ? x : y, equally applicable to props, components and dynamic values:
import { useRoute } from "@t8/react-router";
let App = () => {
let { at } = useRoute();
return (
<header className={at("/", "full", "compact")}>
<h1>App</h1>
</header>
{at("/", <Intro/>)}
{at(/^\/posts\/(?<id>\d+)\/?$/, ({ params }) => <Post id={params.id}/>)}
);
};⬥ params in dynamic values contains the route pattern's capturing groups accessible by numeric indices. Named capturing group values can be accessed by their names, like params.id in the example above.
The SPA navigation API is largely aligned with the similar built-in APIs:
+ import { A, useRoute } from "@t8/react-router";
let UserNav = ({ signedIn }) => {
+ let { route } = useRoute();
let handleClick = () => {
- window.location.href = signedIn ? "/profile" : "/login";
+ route.href = signedIn ? "/profile" : "/login";
};
return (
<nav>
- <a href="/">Home</a>
+ <A href="/">Home</A>
<button onClick={handleClick}>Profile</button>
</nav>
);
};⬥ <A> and <Area> are the two kinds of SPA route link components available out of the box. They have the same props and semantics as the corresponding HTML link elements <a> and <area>.
⬥ The route object returned from useRoute() has: .assign(url), .replace(url), .reload(), .href, .pathname, .search, .hash, .back(), .forward(), .go(delta), resembling the built-in APIs of window.location and history carried over to SPA navigation.
⬥ route.navigate(options) combines and extends route.assign(url) and route.replace(url) serving as a handy drop-in replacement for the similar window.location methods:
route.navigate({ href: "/intro", history: "replace", scroll: "off" });The options parameter is an object combining values corresponding to the link navigation props described in the Link props section below, with the data- prefix stripped from the prop names.
In addition to the props inherited from regular HTML links:
⬥ data-history="replace" prevents the user from returning to the current URL by pressing the browser's Back button after clicking a link with this prop.
⬥ data-spa="off" turns off SPA navigation and triggers a full-page reload.
⬥ data-scroll="off" turns off the default scrolling to the element matching the URL fragment or to the top of the page when the link is clicked.
Routing middleware are optional actions to be done before and after a SPA navigation occurs.
The code below shows some common examples of what can be handled with routing middleware: redirecting to another route, preventing navigation with unsaved user input, setting the page title based on the current URL.
import { useNavigationComplete, useNavigationStart } from "@t8/react-router";
function setTitle({ href }) {
document.title = href === "/intro" ? "Intro" : "App";
}
let App = () => {
let { route } = useRoute();
let [hasUnsavedChanges, setUnsavedChanges] = useState(false);
let handleNavigationStart = useCallback(({ href }) => {
if (hasUnsavedChanges) return false; // prevents navigation
if (href === "/intro") {
route.assign("/"); // redirects
return false;
}
}, [hasUnsavedChanges, route]);
useNavigationStart(handleNavigationStart);
useNavigationComplete(setTitle);
// ...
};⬥ The object passed to the middleware callback contains href and referrer, the navigation destination and initial URLs. The rest of the properties are aligned with the link data-* props, with the data- prefix stripped from the props' names.
⬥ The callback of both hooks is first called when the component gets mounted if the route is already in the navigation-complete state.
⬥ The optional callback parameter of useRoute(callback?) can be used as middleware defining actions to be taken right before or after components get notified to re-render in response to a URL change. This callback receives the render function as a parameter that should be called at some point. Use cases for this callback include, for example, activating animated view transitions or (less likely in regular circumstances) skipping re-renders for certain URL changes.
URL parameters, as a portion of the app's state, can be managed in the React's useState()-like manner, allowing for quick migration from local state to URL parameters or the other way around:
+ import { useRouteState } from "@t8/react-router";
let App = () => {
- let [{ coords }, setState] = useState({ coords: { x: 0, y: 0 } });
+ let [{ query }, setState] = useRouteState("/");
let setPosition = () => {
setState(state => ({
...state,
- coords: {
+ query: {
x: Math.random(),
y: Math.random(),
},
});
};
return (
<>
<h1>Shape</h1>
- <Shape x={coords.x} y={coords.y}/>
+ <Shape x={query.x} y={query.y}/>
<p><button onClick={setPosition}>Move</button></p>
</>
);
};Route state live demo
Type-safe route state live demo
Type-safe routing is as an optional enhancement, allowing for gradual or partial adoption. It's enabled by supporting route patterns created with a type-safe URL builder like url-shape together with a schema created with a validation library implementing the Standard Schema spec, like zod, valibot, arktype, or yup.
import { A, useRoute } from "@t8/react-router";
import { createURLSchema } from "url-shape";
import { z } from "zod";
const { url } = createURLSchema({
"/": z.object({}), // Goes without parameters
"/posts/:id": z.object({
// Path components
params: z.object({
id: z.coerce.number(),
}),
// Similarly a `query` schema can be added here
}),
});
let App = () => {
let { at } = useRoute();
return (
<>
<header className={at(url("/"), "full", "compact")}>
<h1>App</h1>
<nav>
<A href={url("/")}>Intro</A>{" | "}
<A href={url("/posts/:id", { params: { id: 1 } })}>Start</A>
// ^ { params: { id: number } }
</nav>
</header>
{at(url("/"), <Intro/>)}
{at(url("/posts/:id"), ({ params }) => <Post id={params.id}/>)}
// ^ { params: { id: number } }
</>
);
};⬥ The url() function is a type-safe URL builder. It creates a URL with a URL pattern defined in the schema and typed parameters that are prevalidated against the given schema: typos and type mismatches are highlighted in a type-aware code editor. See url-shape for more details.
⬥ A URL schema doesn't have to cover the entire app. Standalone portions of an app can have their own URL schemas.
⬥ Optionally, application-wide type safety can be achieved by disallowing URLs and URL patterns other than provided by the URL builder (the url() function in the example above):
declare module "@t8/react-router" {
interface Config {
strict: true;
}
}Adding this type declaration to an app effectively disallows using string and RegExp values for routes and route patterns (such as in the route link href prop, route.assign(location), and the routing function at(routePattern, x, y)), only allowing values returned from the URL builder with the same routing APIs.
⬥ A URL builder pattern (like url("/posts/:id")) can also be used with useRouteState(pattern) and useRouteMatch(pattern) to manipulate URL parameters in a type-safe manner.
Typed URL parameters state demo
⬥ Recap: It's using typed URL patterns (like from url() of url-shape) that enables type-safe route handling, which is an optional enhancement. Plain string routes and RegExp route patterns are handled with baseline typing sufficient in many cases.
Nested routes don't require special handling. All routes are handled equally and independently from each other.
let App = () => {
let { at } = useRoute();
return (
<>
{at("/about", <About/>)}
{at("/about/contacts", <Contacts/>)}
// ...
</>
);
};In a type-safe setup, a URL schema of a nested route can inherit certain parameters from the parent route. Such relations (which might as well be other than direct nestedness) can be settled within the URL schema with the schema toolset.
import { createURLSchema } from "url-shape";
import { z } from "zod";
let sectionParams = z.object({
sectionId: z.coerce.number(),
});
export const { url } = createURLSchema({
"/posts/:sectionId": z.object({
params: sectionParams,
}),
"/posts/:sectionId/stories/:storyId": z.object({
params: z.object({
...sectionParams.shape, // Shared params
storyId: z.string(),
}),
}),
});Such a setup doesn't impose specific implicit relations between the routes (like parameter inheritance) ahead of time. The relations between the routes, as arbitrary as they can be, are seen and managed directly, allowing for fine-grained control, including sharing or filtering out certain parameters, without the need to work around implicit constraints.
In the browser, the routing hooks like useRoute() assume that the current URL is the browser's one (as exposed with window.location), by default. A custom initial URL value can be set with the <Router> component, which is useful for environments lacking a global URL value, like with server-side rendering or unit tests.
<Router href="/intro">
<App/>
</Router>Now that we've set up a URL context, both route and at() returned from useRoute() inside the <App> component operate based on the router's href:
let { route, at } = useRoute();
console.log(route.href); // returns based on the router's `href`⬥ The <Router>'s href prop value can be either a string URL or an instance of the Route class. The latter option can be used to redefine the default routing behavior (see the Custom routing behavior section). If the href prop value is omitted or undefined, <Router> falls back to the current URL in the browser.
⬥ With SSR in an express application, the URL context setup can be similar to the following:
import { renderToString } from "react-dom/server";
import { Router } from "@t8/react-router";
app.get("/", (req, res) => {
let html = renderToString(
<Router href={req.originalUrl}>
<App/>
</Router>,
);
res.send(html);
});The default URL-based routing behavior is what's used in most cases, but it's also conceivable to have routing based on the URL in a different way or not based on the browser's URL altogether. The <Router> component discussed above (or even multiple ones) can be used to set up customized routing behavior over a specific portion of the app (or the entire app).
Custom routing behavior example
In this example, we've got a kind of a browser-in-browser component with its routing based on a text input rather than the URL. It's enabled by devising a custom extension of the Route class, InputRoute, configured to interact with a text input, and passing its instance to the href prop of the <Router> component.
This example also shows how the same routing code (of the <Content> component) can interact with either the URL or the text input element based on the closest <Router> context up the component tree.
The fallback parameter of the route-matching function at(route, x, y) can be used as a way to handle unknown routes, as shown in the example below. In a type-safe setup, unknown routes can be handled based on whether the given route belongs to the URL schema (e.g. with validate(route) from url-shape).
import { A, useRoute } from "@t8/react-router";
const routeMap = {
intro: "/intro",
posts: /^\/posts\/(?<id>\d+)\/?$/,
};
const knownRoutes = Object.values(routeMap);
let App = () => {
let { at } = useRoute();
return (
<>
<nav>
<A href={routeMap.intro}>Intro</A>
</nav>
{at(routeMap.intro, <Intro/>)}
{at(routeMap.posts, ({ params }) => <Post id={params.id}/>)}
{at(knownRoutes, null, <Error/>)}
</>
);
};The last at() in this example results in null (that is no content) for all known routes and renders the error content for the rest unknown routes.
⬥ at() calls don't have to maintain a specific order, and the at() call handling unknown routes doesn't have to be the last.
⬥ at() calls don't have to be grouped side by side like in the example above, their collocation is not a requirement. at() calls are not coupled together, they can be split across separate components and files (like any other conditionally rendered components).
Lazy routes are routes whose content is loaded on demand, when the route is visited.
Enabling lazy routes doesn't require a specific routing setup. It's a combination of the route matching and lazily loaded React components (with React.lazy() and React's <Suspense>), processed by a code-splitting-capable build tool (like Esbuild, Webpack, Rollup, Vite):
import { useRoute } from "@t8/react-router";
+ import { Suspense } from "react";
- import { Projects } from "./Projects";
+ import { Projects } from "./Projects.lazy";
let App = () => {
let { at } = useRoute();
return (
<>
// ...
{at("/projects", (
- <Projects/>
+ <Suspense fallback={<p>Loading...</p>}>
+ <Projects/>
+ </Suspense>
))}
</>
);
};+ // Projects.lazy.ts
+ import { lazy } from "react";
+
+ export const Projects = lazy(() => import("./Projects"));In this example, the <Projects> component isn't loaded until the corresponding /projects route is visited. When it's first visited, while the component is being fetched, <p>Loading...</p> shows up, as specified with the fallback prop of <Suspense>.
Animated transitions between different routes can be achieved by using the browser's View Transition API and the optional callback parameter of useRoute() can be used to set up such a transition.
+ import { flushSync } from "react-dom";
import { A, useRoute } from "@t8/react-router";
+ function renderViewTransition(render) {
+ if (document.startViewTransition) {
+ document.startViewTransition(() => {
+ flushSync(render);
+ });
+ }
+ else render();
+ }
export const App = () => {
- let { at } = useRoute();
+ let { at } = useRoute(renderViewTransition);
return (
// Content
);
};In the example above, the renderViewTransition() function passed to useRoute() calls document.startViewTransition() from the View Transition API to start a view transition and React's flushSync() to apply the DOM changes synchronously within the view transition, with the visual effects defined with CSS. We also check whether document.startViewTransition is supported by the browser and resort to regular rendering if it's not.
To trigger a transition only with specific links, the options parameter of the useRoute() callback can be used to add a condition for the view transitions. In the example below, we'll only trigger a view transition with the links whose data-id attribute, available via options.id, is among the listed on viewTransitionLinkIds.
+ let viewTransitionLinkIds = new Set([/* ... */]);
function renderViewTransition(render, options) {
- if (document.startViewTransition) {
+ if (document.startViewTransition && viewTransitionLinkIds.has(options.id)) {
document.startViewTransition(() => {
flushSync(render);
});
}
else render();
}A chunk of static HTML content is an example where the route link component can't be directly used but it still might be desirable to make plain HTML links in that content behave as SPA route links. The useRouteLinks() hook can be helpful here:
import { useRef } from "react";
import { useRouteLinks } from "@t8/react-router";
let Content = ({ value }) => {
let containerRef = useRef(null);
useRouteLinks(containerRef);
return (
<div ref={containerRef}>
{value}
</div>
);
};In this example, the useRouteLinks() hook makes all HTML links inside the container referenced by containerRef act as SPA route links.
A selector, or an HTML element, or a collection thereof, can be passed as the second parameter of useRouteLinks() to narrow down the relevant link elements:
useRouteLinks(containerRef, ".content a");