SPA Lazy Loading Pitfalls
You should be lazy loading some of your routes. And if you are, you're probably messing up the data-fetching because it's an easy mistake to make.
See Our Public Workshops:
Lazy loading your pages in an SPA is a great way to reduce your bundle size for new visitors. The problem is, if you're not careful, you'll be creating a much slower experience than if you didn't lazy load.
Imagine this scenario: A user visits an SPA site which does no lazy loading, so all the UI they might want to visit is loaded into the browser in advance. Every time they visit a page, the user just has to wait for data and they see a loading indicator momentarily. This is the normal scenario for a lot of SPA apps.
Now imagine this scenario with lazy loading: A user visits an SPA site which does not have all the UI they might want to see because we will lazy load components we don't have. The user visits a page, we start to lazy load UserProfile.tsx
. Once that page arrives, the useEffect
(or useQuery
, useSWR
, or other) strategy you have for loading data then starts to fetch. But because the user had to wait for the JavaScript file to load for the component, they'll have to wait in serial for the data to load next
GET /components/UserProfile.tsx ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓GET /api/users/1 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
Because of lazy loading, your users might get a smaller JavaScript payload initially but now they wait twice as long for pages to load because they wait for JS then wait for data.
Conventional wisdom says to try a fetching strategy that exists outside your components like React Router loaders. The idea being that when you navigate to this page, React Router will fetch data from your loader function then feed it into your component:
import { loader, UserProfile } from '../components/UserProfile'const router = createBrowserRouter(createRoutesFromElements(<Route path="/" element={<RootLayout />}><Route index element={<HomePage />} /><Route path="/users/:userId" loader={loader} element={<UserProfile />} /></Route>,),)
If you haven't see loaders yet, the idea is that React Router knows what page you want to go to so it will call the async loader function you provide first. Then when it resolves React Router will give the data to your component. You can even use loaders with TanStack Query (formerly React Query).
🔥 Here's the big idea
What if we can load the data with the loader in parallel to loading a lazy component. In theory this should be easy since React Router will fetch from the loader and React can download your component simultaneously. In other words, parallel loading for the fastest page transitions:
GET /components/UserProfile.tsx ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓GET /api/users/1 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
However, there is a subtle problem with this approach based on how you've organized your code.
The problem with loaders and Lazy Loading
One question you'll face when starting to use loaders is "where do I put them?". You'll discover that it is recommended to export them from the same place that your component is like this:
// UserProfile.tsxexport async function loader({ params }) {const user = await fetch(`/users/${params.userId}`).then((r) => r.json())return user}export function UserProfile() {const user = useLoaderData() // returns the result of loader// ...}
It's great from an organizational standpoint, but this is where the problems start with lazy loading. Assuming the above loader
and UserProfile
are in the same file, what do you suppose happens when we lazy load the UserProfile
component but we load the loader into the main bundle with a standard import like this?
import { loader } from '../components/UserProfile'const router = createBrowserRouter(createRoutesFromElements(<Route path="/" element={<RootLayout />}><Route index element={<HomePage />} /><Route path="/users/:userId" loader={loader} lazy={() => import('../components/UserProfile')} /></Route>,),)
Keep in mind that in order to use React Router's lazy
prop, you will have to rename your component from UserProfile
to Component
.
Here's what happens when you load two things from a file and one is lazily loaded. With Vite (and I'm not sure about others), it seems that the two functions (the component and loader) will be loaded into the main bundle with the above example, even though we're asking for the component to be lazily loaded. It kinda makes sense I guess, I'm sure there's implementation details with lazy loading that don't allow us to lazy load a part of a file but not other parts.
I noticed when I checked the network tab and it looked like this with only data fetching and no lazy loading the component:
GET /api/users/1 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
Upon further checks, I can clearly see that my component code is in the main bundle from the beginning so it's pretty concrete that the way I loaded these two things will not let me reach my goal of lazy loading and fetching in parallel.
The solution
In order to get lazy loading to work, while fetching in parallel, you'll need to separate your loader function from the file that has the component. The React Router docs even say this in the section on lazy loading:
Additionally, as an optimization, if you statically define a loader/action then it will be called in parallel with the lazy function. This is useful if you have slim loaders that you don't mind on the critical bundle, and would like to kick off their data fetches in parallel with the component download.
If we had written the code like this, we would get parallel component and data fetching as we wished:
async function loader({ params }) {const user = await fetch(`/users/${params.userId}`).then((r) => r.json())return user}const router = createBrowserRouter(createRoutesFromElements(<Route path="/" element={<RootLayout />}><Route index element={<HomePage />} /><Route path="/users/:userId" loader={loader} lazy={() => import('../components/UserProfile')} /></Route>,),)
Take it for what it's worth, now back to that original question:
Where should I put my loaders?
I'll leave that for you to figure out based on your current organization :)