I'm writing this blog post from an admin interface I built, which is a web app frontend for the backend behind peterbe.com. It's built as a single-page app in Vite, with React.
Vite, unlike frameworks like Remix or Next, doesn't come with its own routing. You have to add that yourself. I added React Router. Another thing you have to do yourself is a way to load remote data into the app for display and for manipulation. This is done as XHR requests happen on the client side. For that, I chose TanStack Query. If you haven't used it but used React, it's sugar for this type of code:


// DON'T DO THIS. USE TANSTACK QUERY

const [stuff, setStuff] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
  fetch('/api/some/thing')
    .then(r => r.json())
    .then(data => setStuff(data)
    .catch(err => setError(err))
}, [])

Waterfalls

However, the main flaw with this approach is that it leads to a waterfall of events, which can be avoided. This waterfall effect causes the page to not feel as performant as it can be. You're missing an opportunity to start something that can work in the background.
What happens is:

  1. The HTML page is requested, the HTML page contains <script src="...js">
  2. The .js is downloaded, parsed, and eventually executed
  3. The JavaScript execution is React rendering (with React Router for figuring out what component to render based on window.location.pathname)
  4. Once the React rendering has finished and the DOM has been fully formed, the useEffect hooks are executed
  5. That's when the fetch('/my/api/url') commences

What this blog post is about is to change that order so it instead becomes

  1. The HTML page is requested, the HTML page contains <script src="...js">
  2. The .js is downloaded, parsed, and eventually executed
  3. The JavaScript execution starts a fetch (NOTE!)
  4. The JavaScript execution is React rendering (with React Router for figuring out what component to render based on window.location.pathname)
  5. Once the React rendering has finished and the DOM has been fully formed, the useEffect hooks are executed
  6. TanStack Query runs in a useEffect and it notices that a fetch Promise has already been started

Essentially, during the time it takes to React render the DOM, an asynchronous XHR request is waiting for the server to respond. Good use of time, for an optimally responsive web app.

What the code looks like

The "trick" is that React Router gives you a callback function called loader which is executed before the rendering starts. If you use await inside that, it's blocking, which means it doesn't start rendering the page until it has been resolved. You get an entirely white screen (or black depending on your CSS theme) until that has resolved, and then it starts to render the page. It has its uses, and you can set up a HydrateFallback component to be rendered instead of a blank screen.

But the inner core of it is that you use the same QueryClient instance within the loader as you use in the app. In my case, it looks something like this:


// routes.tsx

export const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    loader: rootLoader,

// query-client.ts

import { QueryClient } from "@tanstack/react-query"
export const queryClient = new QueryClient()

// loader.ts

import { queryClient } from "../query-client"
export async function loader() {
  return {
    countUnapprovedComments: queryClient.fetchQuery({
      queryKey: commentsCountQueryKey(),
      queryFn: fetchCommentsCount,
      staleTime: 5000,
    }),
  }
}

// App.tsx

import { queryClient } from "./query-client"
import { router } from "./routes"

export default function App() {
  return (
    <ThemeProvider>
      <QueryClientProvider client={queryClient}>
        <RouterProvider router={router} />
      </QueryClientProvider>
    </ThemeProvider>
  )
}

// Nav.tsx

function Nav() {
  const {data, isPending, error} = useQuery<Counts>({
    queryKey: commentsCountQueryKey(),
    queryFn: fetchCommentsCount,
  })
  ...
}

That's a lot of abbreviated code. You can poke around all you like on https://github.com/peterbe/admin-peterbecom.

No Suspense

If you don't use TanStack Query as the "store" for your XHR fetches, you have to use React Suspense. From the documentation the primary example looks like this:


import { Suspense } from "react";
import { Await, useLoaderData } from "react-router-dom";

...

  ...
  const data = useLoaderData();
  ...

      <Suspense
        fallback={<p>Loading package location...</p>}
      >
        <Await
          resolve={data.packageLocation}
          errorElement={
            <p>Error loading package location!</p>
          }
        >
          {(packageLocation) => (
            <p>
              Your package is at {packageLocation.latitude}{" "}
              lat and {packageLocation.longitude} long.
            </p>
          )}
        </Await>
      </Suspense>

Here, the data.packageLocation is a Promise from a loader function. It's a bit messy. You have to use the fallback prop to Suspense for your loading state. In TanStack Query, it can/could look like this instead:


  const {data, isError, isPending} = useQuery(...)

  if (isPending) {
    return <p>Loading package location...</p>
  }
  if (isError) {
    return <p>Error loading package location!</p>
  }
  const packageLocation = data
  return (
    <p>
      Your package is at {packageLocation.latitude}{" "}
      lat and {packageLocation.longitude} long.
    </p>
  )

The bonus!

What I think is so neat about all of this is that it's "optional" (and "optimal"!).
If you have a single page app, that does a bunch of data fetching with XHR, you probably have something organized like TanStack Query to handle it. You get caching, loading state, error state, and nifty functionality like retries. All of that needs to be built and you need to write those fetch functions. Now, once you have all of that, you can opt in to the optimization trick of commencing these XHR requests before the rendering. All you need to do is refactor your query functions in a way that they can be shared in two places.

If I do this to my app...:


export const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
-   loader: rootLoader,
+   //loader: rootLoader,
    id: "root",
    errorElement: <ErrorPage />,
    children: [

nothing, really, changes. The functionality stays the same. It just means that the XHR fetch request starts after the initial React rendering.

In conclusion

If you use loaders in React Router and have them start XHR requests on the same QueryClient you use in your components, you can start API requests before you render the React page. This makes the page feel faster to load because you can have the back-end data ready for your eyes to feast on sooner.

Comments

Your email will never ever be published.

Related posts