[]previous and next page

React Router to ShadowJS

Port React Router and Remix navigation to @shadow-js/router with a codemod for links, the outlet, redirects and the navigation hooks, and a map from route objects, loaders and the router provider to defineRoute and createRouter.

For agentsUpdated

React Router and @shadow-js/router solve the same problem with different shapes. Links, the outlet and the navigation hooks map almost one to one; the route table becomes defineRoute objects; loaders keep their idea (data before render) and gain types from the path. This page is the router part of a port: components, hooks and state are in React to ShadowJS, and file routes with a server are Dusk.

Automate it#

create-shadow-app codemod react-router-to-shadow rewrites what maps without judgment, for imports from react-router, react-router-dom and @remix-run/react: Link and NavLink become A, and Outlet, Navigate, useNavigate, useLocation and useParams move to @shadow-js/router. It parses each file, edits by source range and prints what it left for you. Run it without a flag to see a diff and write nothing, then again with --write. A second run changes nothing. See Migrate code with codemods for the options and the exit codes.

sh
npx create-shadow-app codemod react-router-to-shadow src
npx create-shadow-app codemod react-router-to-shadow src --write
React Router The codemod writes Why
<Link to="/x">, <NavLink to="/x"> <A to="/x"> NavLink's active state is activeClass (and end) on A
className="x" on a link class="x" A joins class with activeClass
prefetch="intent" (or viewport, render, none) preload="intent" The same four strategies; render and none need prefetch()
activeClassName (v5) activeClass
Outlet, Navigate, useLocation the same names from @shadow-js/router
useNavigate, useParams the same names from @shadow-js/router useParams returns a view; a note says to read it where it is used

It refuses, with the file:line and the reason, and leaves the import alone: a to that is an object ({ pathname, search }), className or style written as a function of { isActive }, reloadDocument, preventScrollReset, target, download, discover, a spread of props on a link, and navigate(-1) (a number moves through history; write useRouter().history.back()). Everything else from the React Router packages is reported with what to write instead. This is the exact output for the input shown:

nav.before.tsxtsx
import { Link, NavLink, Navigate, Outlet, useLocation, useNavigate, useParams } from "react-router-dom";

export function Shell() {
  const navigate = useNavigate();
  const location = useLocation();
  const { id } = useParams();

  return (
    <div>
      <nav>
        <Link to="/" className="brand" prefetch="intent">
          Home
        </Link>
        <NavLink to="/posts" end activeClassName="current">
          Posts
        </NavLink>
        <a href="/about">About</a>
      </nav>
      <button onClick={() => navigate("/posts/new", { replace: true })}>New</button>
      <p>
        {location.pathname} {id}
      </p>
      {id === undefined ? <Navigate to="/posts" replace /> : <Outlet />}
    </div>
  );
}
nav.after.tsxtsx
import { A, Navigate, Outlet, useLocation, useNavigate, useParams } from "@shadow-js/router";

export function Shell() {
  const navigate = useNavigate();
  const location = useLocation();
  const { id } = useParams();

  return (
    <div>
      <nav>
        <A to="/" class="brand" preload="intent">
          Home
        </A>
        <A to="/posts" end activeClass="current">
          Posts
        </A>
        <a href="/about">About</a>
      </nav>
      <button onClick={() => navigate("/posts/new", { replace: true })}>New</button>
      <p>
        {location.pathname} {id}
      </p>
      {id === undefined ? <Navigate to="/posts" replace /> : <Outlet />}
    </div>
  );
}

What you write by hand#

The route table#

<BrowserRouter>, <Routes>, <Route>, createBrowserRouter and createRoutesFromElements become defineRoute objects and one createRouter. A route's element is its component; path keeps its :param syntax; nesting is children, and <Outlet /> renders the child.

src/app.tsxtsx
import { createRouter, defineRoute, Outlet, RouterProvider } from "@shadow-js/router";
import type { JSX } from "@shadow-js/web";

const posts = defineRoute("/posts", {
  component: () => (
    <section>
      <h1>Posts</h1>
      <Outlet />
    </section>
  ),
  children: [
    defineRoute("/:id", { component: () => <article>One post</article> }),
  ],
});

const home = defineRoute("/", { component: () => <h1>Home</h1> });

export const router = createRouter({ routes: [home, posts] });

export function App(): JSX.Element {
  return <RouterProvider router={router} />;
}

Loaders#

A React Router loader returns a value that useLoaderData() reads. In ShadowJS the loader gets { params, search, context, signal } and useLoaderData() returns an accessor: read the data with (). A loader that throws redirect(...) or notFound() works as in React Router, with the helpers from @shadow-js/router. Loaders run in parallel for the whole chain of matched routes, beforeLoad runs top-down first, and the lazy code of each route is requested together with its data, so the cost of a navigation is the slower of the two.

src/post.tsxtsx
import { defineRoute, useLoaderData } from "@shadow-js/router";

interface Post {
  title: string;
}

function PostPage() {
  const post = useLoaderData<Post>();
  return <h1>{post()?.title}</h1>;
}

export const post = defineRoute("/posts/:id", {
  loader: ({ params }): Post => ({ title: `Post ${params.id}` }),
  component: PostPage,
});

Search params, forms and actions#

useSearchParams becomes useSearch() for validated values (give the route a validateSearch) or useLocation().search for the raw string. React Router's <Form> and actions, and Remix's useFetcher, correspond to server functions and <Form>, useFetcher and useSubmit of @shadow-js/dusk/client; see Dusk: mutations.

Scroll restoration and view transitions#

<ScrollRestoration /> is the scroll() feature of @shadow-js/router/scroll. unstable_viewTransition is the viewTransitions() feature (@shadow-js/router/transitions), with viewTransition={false} to opt a link out.

See also#