Skip to content

Instantly share code, notes, and snippets.

@ryanflorence
Last active May 29, 2023 05:25
Show Gist options
  • Star 13 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ryanflorence/fc67c2b689c5ecdca7185763a92f120a to your computer and use it in GitHub Desktop.
Save ryanflorence/fc67c2b689c5ecdca7185763a92f120a to your computer and use it in GitHub Desktop.

Route Transition API

Definitions

The goal of the route transition API is to enable suspense-like transition in React Router without using Suspense (much like v1).

On location changes, React Router will continue to send down the old location, activating pending hooks for loading states and optimistic UI, and wait for your Route's preloading hooks to resolve before sending down the new location and updating your app.

This enables you to declare data dependencies on your routes, allowing your route elements to expect data and not need to manage their own loading states.

The API

Let's start with these routes.

function App() {
  return (
    <Routes element={<Root />}>
      <Route path="/" element={<Home />} />
      <Route path="team" element={<Team />}>
        <Route path="/" element={<TeamIndex />} />
        <Route path=":member" element={<TeamMember />} />
      </Route>
    </Routes>
  );
}

New API: <Routes fallback>, <Route loader>

Let's assume that:

  • <Root> needs to fetch the current user
  • <Team> needs to fetch a github org team
  • <TeamMember> needs to fetch a team member

Here's how the new API works out:

let getCurrentUser = () => fetchJSON("/api/currentUser");
let getTeam = () => fetchJSON("/api/team");
let getMember = params => fetchJSON(`team/${params.member}`);

function App() {
  return (
    <Routes fallback={<div>Loading...</div>}>
      <Route element={<Root />} loader={getCurrentUser} />
      <Route path="/" element={<Home />} />
      <Route path="team" element={<Team />} loader={getTeam}>
        <Route path="/" element={<TeamIndex />} />
        <Route path=":member" element={<TeamMember />} loader={getMember} />
      </Route>
    </Routes>
  );
}

Note that <Routes> is the only element with a fallback. That's because fallback is only used for the initial render, and not needed at all when server rendering provides initial data, as we'll see later).

On the initial render, the fallback will render as data is fetched.

Every render after that the old tree will render while loading happens because <Routes> will send down the old location while the transition is pending.

The element on <Routes> is a root layout for the routes, the matching routes will render inside its outlet as if it were a layout route like any other.

New API: useRouteData

The resolved value from loader will be returned in useRouteData. This value will always be the resolved value of the loader prop. The component will not be rendered initially, nor render again after a location update, until the loader prop has resolved.

import { Outlet, useRouteData } from "react-router-dom";

function Root() {
  let user = useRouteData();
  return (
    <div>
      <h1>Welcome {user.name}</h1>
      <Outlet />
    </div>
  );
}

All the other routes will be similar:

import { Outlet, useRouteData } from "react-router-dom";

function Team() {
  let team = useRouteData();
  return (
    <div>
      <h2>Team</h2>
      <nav>
        <ul>
          {team.map(member => (
            <li>
              <Link to={member}>{member.name}</Link>
            </li>
          ))}
        </ul>
      </nav>

      <Outlet />
    </div>
  );
}

function TeamMember() {
  let member = useRouteData();
  return (
    <div>
      <h3>{member.name}</h3>
      <Profile user={member} />
    </div>
  );
}

Data Loader Diffing

On route transitions, only the changed routes' loaders are called to prevent overfetching of data the page already has that persists between the transition. Updating data at a route will be discussed later with <Form>.

Initial loader values

In the case of SSR, where the server has already provided the initial data (like in Remix), you can pass the initial data to the routes and the fallback can be removed.

In order for this to work, every route in the tree needs the initial data, if any one of them is missing, React Router will warn to the console, render null, and fetch the data.

This behavior is controlled by the presence or non-presence of <Routes fallback>. If fallback is defined, then <Routes> will render the fallback initially, whether initial values are provided or not. If fallback is not defined, then <Routes> will attempt to render the tree, and if any initial values are missing, then React Router will render null and fetch the data.

let getCurrentUser = () => fetchJSON("/api/currentUser");
let getTeam = () => fetchJSON("/api/team");
let getMember = params => fetchJSON(`team/${params.member}`);
let ssrContext = typeof window !== "undefined" && window.ssrContext;

function App() {
  return (
    <Routes
      element={<Root />}
      loader={getCurrentUser}
      initialData={ssrContext?.root}
    >
      <Route element={<Home />} />
      <Route
        path="team"
        element={<Team />}
        loader={getTeam}
        initialData={ssrContext?.team}
      >
        <Route path="/" element={<TeamIndex />} />
        <Route
          path=":member"
          element={<TeamMember />}
          loader={getMember}
          initialData={ssrContext?.member}
        />
      </Route>
    </Routes>
  );
}

Pending UI

To display pending UI during transitions as loader values are resolved, there's a new hook:

New API: usePendingLocation

This hook will return the next location while loader values are inflight. This makes it easy to create a global pending indicator on the root layout:

import { Outlet, useRouteData, usePendingLocation } from "react-router-dom";

function Root() {
  let user = useRouteData();
  let pendingLocation = usePendingLocation();
  return (
    <div>
      <h1>Welcome {user.name}</h1>
      <div style={{ opacity: !!pendingLocation ? "0.25" : "" }}>
        <Outlet />
      </div>
    </div>
  );
}

Individual links that are being navigated to can also participate in pending ui, just like activeClassName and activeStyle

<NavLink
  to={member}
  pendingClassName="pending"
  pendingStyle={{ color: "green" }}
>
  {member.name}
</NavLink>

**New API: usePendingMatch

For more generic cases, you can implement pending UI that matches a path with usePendingMatch(to). This supports relative URLs just like <Link to>.

function SpinnerLink({ to, children, ...props }) {
  let isPending = usePendingMatch(to);
  return (
    <Link to={to} {...props}>
      {children}
      {isPending && <Spinner />}
    </Link>
  );
}

New API: <Routes onTransitionError>

Since React Router is the one awaiting your route loaders, it needs to give you an opportunity to handle uncaught errors. Typically route loaders should have caught their own errors, but in the case that they did not, this will be called.

<Routes onTransitionError={({ match, error }) => {
  // do something with match/error
  return valueForTheRouteLoaderReturn;
}}>

Default behavior is ({ error }) => error, meaning your useRouteData() call will get an error object.

New API: <Routes onBeforeTransition>

This hook gives you a chance to do something before the transition starts.

<Routes onBeforeTransition={async ({ matches }) => {
  return void;
}}>

New API: <Routes onBeforeTransitionComplete>

This hook gives you a chance to do something with all of the results together. Since each route's loader is called in parallel, you can't really do anything inside the loader when you're needing to process them in order from parent to child route. In Remix, this is when we know to redirect the browser, first route to redirect wins, rather than first to load wins.

<Routes onBeforeTransitionComplete={async ({ matches }) => {
  matches[0] = {
    route: { path, ...routeProps },
    params,
    data // <- return value from route loader
  }

  return matches;
}}>

<Form> and Actions

Anytime you had a data "read" API, you need to also provide a data "write" API. On the web, <a href> is the read and <form> is the write. With React Router <Link> is the read and <Form> is the write.

New API: <Route action> and <Form>

In addition to a loader, each route can define an action. When a <Form> is submit, React Router will match the routes the form submits to (<Form action="/some/path">), get the leaf match, serialize the form into new FormData(), and pass the form data to the route's action.

You then return a new location from the action to redirect elsewhere after the action is complete.

A data mutation can potentially change data at any route in the application. So, unlike normal transitions where only the changed routes' loaders are called, after an action all matching routes' loaders will be called to make sure all data on the page is fresh.

New API: usePendingFormSubmit()

While an action is inflight, usePendingFormSubmit() will return information about the form submit, like the method ("post", "get"), and the serialized formData that went to your action.

This information allows you to build pending UI as well as "optimistic UI". You can use the pending form data to render an item that is being created, then present a message only if it fails.

Getting matches in layouts

Sometimes you need to know the matches of a route tree in the root layout, or route layout (sorry for those of you who say those two words the same, but you're gonna have to deal with that!). A the proverbial example of this is creating breadcrumbs.

When it comes to breadcrumbs, not only do you need to know the matching routes, but also the data to put the right values in the crumb.

New API: useMatches

The useMatches hook will return all of the matches and data including and below the layout route it's used in. A match looks like this:

{
  route: { path },
  pathname,
  params,
  data
}

This hook returns an array of the route matches, starting with the layout route it's used in, down to the deepest matching child route. With this information, we can build our breadcrumbs. Assuming each one resolves with a breadCrumbText value or a static prop like <Route path="team" breadCrumbText="Team" />, we can make this nice and generic:

import {
  Outlet,
  useRouteData,
  usePendingLocation,
  useMatches
} from "react-router-dom";

function Root() {
  let user = useRouteData();
  let pendingLocation = usePendingLocation();
  let matches = useMatches();

  return (
    <div>
      <nav>
        {matches
          .map(match => (
            <Link to={match.pathname}>
              {
                // if the route's data defined it, use that
                match.data.breadCrumbText ||
                  // otherwise use a non-data-driven value
                  match.route.breadCrumbText
              }
            </Link>
          ))
          .join(" / ")}
      </nav>
      <h1>Welcome {user.name}</h1>
      <div style={{ opacity: !!pendingLocation ? "0.25" : "" }}>
        <Outlet />
      </div>
    </div>
  );
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment