Skip to content

Integrating Remix Router with React Transitions #5763

Closed
@gaearon

Description

@gaearon

What version of Remix are you using?

1.14.1

Are all your remix dependencies & dev-dependencies using the same version?

  • Yes

Steps to Reproduce

Remix does not fully integrate with Suspense because the Remix router does not use startTransition. I was wondering if there is interest in exploring a fuller integration where Remix router would be more Suspense-y.

To illustrate this, I will offer a little example.

Initial setup

Suppose I have an /index route that just renders <h1> and a more complex /about page that loads some data. My navigation to /about looks like this:

remix_good.mov

The data loads in two stages:

  1. First, the "blocking" part holds the router navigation back.
  2. Then, when the loader is ready, we show some content and "defer" the rest.

My code for about.js looks like this:

import { Suspense } from 'react'
import { Await, useLoaderData } from "@remix-run/react";
import { defer } from "@remix-run/node";

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms))
}

export async function loader({ request }) {
  await sleep(1000);
  return defer({
    username: 'taylor',
    more: sleep(3000).then(() => ({ bio: 'Hi there!' }))
  })
}

export default function Index() {
  const data = useLoaderData();

  return (
    <>
      <h1>About {data.username}</h1>
      <Suspense fallback={<h2>Loading bio...</h2>}>
        <Await resolve={data.more}>
          {more => <p>{more.bio}</p>}
        </Await>
      </Suspense>
    </>
  );
}

So far so good.

A wild suspense appears!

Now let's say I add a component that suspends to my /about page.

import { lazy, Suspense } from 'react'
import { Await, Link, useLoaderData,  } from "@remix-run/react";
import { defer } from "@remix-run/node"; // or cloudflare/deno
+ import Counter from '../counter'
...
export default function Index() {
  const data = useLoaderData();

  return (
    <div>
      <h1>about {data.username}</h1>
+     <Counter />
      <Suspense fallback={<h2>Loading bio...</h2>}>
        <Await resolve={data.more}>
          {more => <p>{more.bio}</p>}
        </Await>
      </Suspense>
    </div>
  );
}

Suppose that Counter itself suspends for whatever reason. E.g. maybe it renders a lazy component inside. Maybe it does a client fetch with one of the Suspensey libraries. Maybe it is waiting for some CSS chunk to load (when we add Suspensey CSS integration into React). Etc. In general, you can expect components anywhere in the tree to be able to suspend — and that includes the "non-deferred" parts of the UI.

At first, we'll have an error:

Screenshot 2023-03-10 at 20 39 47

OK, fair enough, it's a React error. It says there's no <Suspense> above it in the tree. Let's fix this.

I could add it around my outlet:

+        <Suspense fallback={<h1 style={{ color: 'red'}}>loading</h1>}>
            <Outlet />
+        </Suspense>

But then the whole page "disappears" when it suspends:

remix_flash1.mov

Or I could add it around the <Counter> itself:

      <h1>about {data.username}</h1>
+     <Suspense fallback={<h1 style={{ color: 'red'}}>loading</h1>}>
          <Counter />
+     </Suspense>
      <Suspense fallback={<h2>Loading bio...</h2>}>

But then my Counter "pops in" independently from the <h1>.

remix_flash2.mov

What I really want is to delay the route transition until all content in the "blocking" part (including <Counter /> and whatever it suspended on) is ready.

Is this even possible to fix?

Yes. The canonical fix for this is to wrap the setState which caused the navigation into startTransition. This will tell React that you don't expect that setState to finish immediately, and so it can keep showing the "previous" UI until the render caused by that setState has enough data to show meaningful UI.

As a concrete example, here is a similar navigation implemented in Next.js 13 (App Router):

next_good.mov

Note that although <Counter /> suspends on the client (with an artificial delay), the router still "waits" for it — it "pops in" together with the <h1>. I did not need to add any <Suspense> boundaries — neither around it nor above it.

import { Suspense } from "react";
import Counter from '../counter'

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function loadUsername()  {
  await sleep(1000);
  return "taylor";
}

async function loadBio() {
  await sleep(3000);
  return "hi there";
}

export default async function About() {
  const bioPromise = loadBio();
  const username = await loadUsername();
  return (
    <>
      <h1>about {username}</h1>
      <Counter   />
      <Suspense fallback={<h2>Loading bio...</h2>}>
        <Bio data={bioPromise} />
      </Suspense>
    </>
  );
}

async function Bio({ data }) {
  const bio = await data;
  return <p>{bio}</p>;
}

Of course, if I wanted to, I could add a <Suspense> boundary around just the <Counter>. Then it would not "hold" the router from a transition.

What's the higher-level point?

Remix currently implements its own machinery to "hold things back" until loaders have returned their result. Then it immediately "commits" the UI — even if that UI might immediately suspend.

React implements a first-class feature to "hold things back" — startTransition. (Or useTransition so you can report progress.) So I am wondering if there is some path towards using the built-in feature.

I imagine that, if Remix were to do that, it would start rendering the new route immediately — but it would suspend when in useLoaderData. If the router setState is wrapped in startTransition, you'd automatically get the "stay on the previous route until there's enough data to show the next route's shell" behavior. But unlike now, it would be integrated with anything else that suspends, like <Counter />.

Would love to hear any concerns or if there's something missing in React to make this happen. It's exciting Remix is taking advantage of Suspense for React SSR streaming already. Integrating Remix Router with React Transitions seems like a big next step towards Remix users being able to benefit from all React features.

Expected Behavior

  • Remix router transitions are React Transitions (see above)

Actual Behavior

  • Remix router transitions are not React Transitions (see above)

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions