One of the major pain-points in a client side SPA is the waterfall. The waterfall is a term used to describe the time it takes for the first byte of data to be received by the client. This is a problem because it means that the client has to wait for the entire page to be loaded before they can interact with it. This can be especially frustrating for users on slow connections or devices.
One way to mitigate this problem is to render the whole HTML on the server and returning as a static content. As the user starts to interact with it, the client side JavaScript takes over and hydrates the page. It can further be optimized by using resumability
.
But the sad part is that, This feature cannot be achieved in a SPA (you need a server to render the page).
In a typical React application, the data fetching and rendering are tightly coupled. This means that the data fetching and rendering are done in the same component, which can lead to waterfall.
For example, consider the following code:
export default function Component() {
const { data, isPending } = useQuery('todos', () =>
fetch('https://jsonplaceholder.typicode.com/todos').then((res) =>
res.json()
)
);
if (isPending) return <div>Loading Todos...</div>;
return (
<div>
{data.map((todo) => (
<div key={todo.id}>{todo.title}</div>
))}
</div>
);
}
const Component = React.lazy(() => import('./component'));
function App() {
// The router part is just a placeholder
return (
<Router>
<Route
path='/'
element={
<Suspense fallback={<div>Loading...</div>}>
<Component />
</Suspense>
}
/>
</Router>
);
}
In the above example, the component fetches the data and renders it in the same component. This is a problem
- You need component to be mounted to fetch the data.
- And then, you need data to be fetched to render the component.
The browser has to
- first download the JavaScript bundle, parse it and execute it. (user sees blank screen)
- Then, the component is mounted and the data is fetched. (user sees loading spinner)
- Finally, the data is rendered. (user sees the content)
This involves the above sequential steps, which can lead to waterfall. The user has to wait for a longer time to see the content.
What can we do to solve this problem?
What if we could just fetch the data and the component in parallel? This way, the user can see the content as soon as possible.
And as a matter of fact, we can totally do that. We can fetch the data and the component in parallel and render the component as soon as the data is available. This transforms those sequential network requests into parallel requests, making the perceived performance much better.
This is called render as you fetch
.
The basic idea is to eager the data fetching. This means that we fetch the data as soon as possible, even before the component is mounted.
export default function Component({ data }) {
return (
<div>
{data.map((todo) => (
<div key={todo.id}>{todo.title}</div>
))}
</div>
);
}
const Component = React.lazy(() => import('./component'));
function App() {
const { data, isPending } = useQuery('todos', () =>
fetch('https://jsonplaceholder.typicode.com/todos').then((res) =>
res.json()
)
);
if (isPending) return <div>Loading Todos...</div>;
return (
<Router>
<Route
path='/'
element={
<Suspense fallback={<div>Loading...</div>}>
<Component data={data} />
</Suspense>
}
/>
</Router>
);
}
You can fetch the data in the parent component and pass it as props to the child component. (This is the most common way).
While this is a good solution, it has some drawbacks:
- The parent component has to know about the data requirements of the child component.
- The parent component has to fetch the data, even if the child component is not rendered.
- The parent component has to handle the loading, error, filters etc. states of the data.
- The simple example looks cool but as the app grows, this becomes the most common anti-pattern.
- ... and the list goes on
React 18 introduces a new feature called Suspense for data fetching
. This allows you to fetch the data in the component itself, without having to worry about the parent component.
This is too, not very different from the initial example and in a way share the common problems
The best way to solve this problem is to use your router for data fetching. This way, you can fetch the data and the component in parallel, without having to worry about the parent component.
@tanstack/react-router
is a library which allows you to attach loaders to the routes. This way, you can fetch the data and the component in parallel.
import { createRoute } from '@tanstack/react-router';
export const route = createRoute({
path: '/',
component: Component,
loader: async () => {
return fetch('https://jsonplaceholder.typicode.com/todos').then((res) =>
res.json()
);
},
});
function Component() {
const data = route.useLoaderData(); // get the data from the loader itself
// instead of this pattern, you can use `@tanstack/react-query`
// to populate the data in the cache and use it in the component
return (
<div>
{data.map((todo) => (
<div key={todo.id}>{todo.title}</div>
))}
</div>
);
}
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider, createRootRoute, createRouter } from '@tanstack/react-router'
import route from './component';
const routeTree = rootRoute.addChildren([route]);
const router = createRouter({
routeTree,
defaultPreload: 'intent',
scrollRestoration: true,
});
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
const rootElement = document.getElementById('app')!
if (!rootElement.innerHTML) {
const root = createRoot(rootElement)
root.render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
)
}
Alternatively, you can use a super component to wrap such components which makes the API call before-hand and call the component with the data (using react suspense)
In this article, we discussed the problem of waterfall in a SPA and how to solve it using render as you fetch
. We discussed the different ways to solve this problem and the best way to solve it using your router for data fetching.