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:
- The HTML page is requested, the HTML page contains
<script src="...js">
- The
.js
is downloaded, parsed, and eventually executed - The JavaScript execution is React rendering (with React Router for figuring out what component to render based on
window.location.pathname
) - Once the React rendering has finished and the DOM has been fully formed, the
useEffect
hooks are executed - That's when the
fetch('/my/api/url')
commences
What this blog post is about is to change that order so it instead becomes
- The HTML page is requested, the HTML page contains
<script src="...js">
- The
.js
is downloaded, parsed, and eventually executed - The JavaScript execution starts a
fetch
(NOTE!) - The JavaScript execution is React rendering (with React Router for figuring out what component to render based on
window.location.pathname
) - Once the React rendering has finished and the DOM has been fully formed, the
useEffect
hooks are executed - TanStack Query runs in a
useEffect
and it notices that afetch
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