Photo by Taylor Vick on Unsplash
Data fetching patterns - balancing client and server with SWR and Next.js
Table of contents
With the advent of modern component JS-based full-stack frameworks, we now have the flexibility to get remote data into our apps either via client
or server
.
Why does this matter?
Based on how we get data into our system we make different trade-offs with core web vitals.
For example -
If we get all the data from the server we would have to wait for all the data to be available. So the user would have to wait for more time to see something on the screen (TTFB).
So should we shift all the network calls on to the client then 🤔 ?
Lots of loading spinners! 🌀
Well turns out the answer is nuanced - not surprising at all is it 😃.
A good rule of thumb would be if the information is above the fold or critical we shouldn’t make the user see spinners as much as possible. And if something is quite hidden from the initial UI can be potentially loaded “lazily”.
But that means some calls we need to make some calls on the server and some on the client. That requires granular control over which calls need to be executed on the client and which on the server. And hopefully with little or no extra code.
Also what about data changing over time?
Perhaps something like product availability in an e-commerce application that we can load initially at the time from the server but can potentially change while the user is browsing? We would need some ability to re-validate 🤔.
To solve these problems we have some awesome libraries namely:
I have chosen Next.JS
as the framework and SWR
as the library to showcase different ways to leverage the different options at our disposal.
I have created a demo weather
app to showcase different variations of data fetching.
Looks something like this:
Let's dig in!
Pure Client Side
This is the default approach with swr
all you need to do is use the hook and provide it with a unique key
to identify the remote call.
In this case, we will use the location
as the key
so the code will look something like this:
function Weather() {
// first param is the key and second argument is custom fetcher.
// the key is passed to custom fetcher.
const { data, error } = useSWR(location, getWeatherInfo);
if (error) {
return <p>Error!</p>;
}
if (!data) {
return <p>Loading..</p>;
}
return (
<>
<p>{data.temperature}</p>
<p>{data.humidity}</p>
</>
);
}
I have used the same custom fetch client that I blogged about last time:
Making impossible states impossible ft. Zod and Typescript
You can go through it for more info.
So the initial UI user will see will be something like this (loading indicators..):
For full-fledged code for this approach:
Prefetch on Server
As you can see from the above screenshot I have chosen london
as the first location.
We could show the user an loading
indicator and make users wait before they can see the weather info.
But why do it when we know that is the first thing user will see?
At the same time doesn’t make sense the get weather info for all cities on the server either. Users would stare at a white screen longer than necessary.
So for this application, we will only fetch
weather info for london
on the server and will load the rest on the client.
In next.js
to make a server-side call we need to use the aptly-named function getServerSideProps
.
And to use it with swr
we need to pass the data to fallback
prop in the context that swr
provides called SWRConfig
.
So the page
would look something like this:
function Page(props) {
return (
<SWRConfig value={{ fallback: props.fallback }}>
{/* Your App */}
<Weather />
</SWRConfig>
);
}
// london data is prefetched!
export async function getServerSideProps(context: GetServerSidePropsContext) {
const weatherResponse = await getWeatherInfo("london");
return {
props: {
fallback: {
london: weatherResponse,
},
},
};
}
Full-fledged code:
Let’s take a moment and talk about this in a bit more detail.
The demo app that I have created has a few cities as options and we were able to leverage SWR
and Next.JS
to shift the remote call from the client to the server.
Now let’s take a step ahead here and assume that during peak traffic time the weather API became slow so users would be staring at a white screen.
What if we had the ability to switch over to a pure client-side solution in such a situation?
Because in the above case that would be a better option. Instead of seeing a white screen users would see a loading indicator like in the pure client side
approach.
Well for that we just need to remove the getServerSideProps
call and we would be good.
So something like this:
function Page(props) {
return (
<SWRConfig value={{ fallback: props.fallback }}>
{/* Your App */}
<Weather />
</SWRConfig>
);
}
// london data is prefetched!
export async function getServerSideProps(context: GetServerSidePropsContext) {
// add a feature flag - what ever mechanism your org uses.
const featureFlags = getFeatureFlags();
if (!featureFlags.clientOnly) {
const weatherResponse = await getWeatherInfo("london");
}
// sending an empty fallback if clientOnly flag is true
return {
props: {
fallback: featureFlags.clientOnly
? {}
: {
london: weatherResponse,
},
},
};
}
And et voila - we have shifted the call’s happening on the client!
This is quite handy - especially during a pager duty
alert 😅 .
Also, we can reuse a lot of code here too:
// on the server - to get weather data:
const weatherResponse = await getWeatherInfo("london");
// on the client
const { data, error } = useSWR(location, getWeatherInfo);
Since we are using the same getWeatherInfo
the function we get type-safety across the client and server.
There are plenty more things this enables for us:
If the call fails with an
error
on the server, we can send out an emptyfallback
and the client will try the call!We can set some timeout duration on the server and throw an error and have the client make the call!
Apply some retry options via
swr
on client when if it errors out or times out.
I will let you folks come up with creative solutions here, but I think you all got the gist.
Prefetch on the client
Turns out we can do this via good old link
tags on html
:
<link rel="preload" href="/api/weather/mumbai" as="fetch" crossorigin="anonymous">
Or like this :
import useSWR, { preload } from 'swr'
// ain't that cool!
preload('kolkata', getWeatherInfo);
function Weather() {
// first param is the key and second argument is custom fetcher.
// the key is passed to custom fetcher.
const { data, error } = useSWR(location, getWeatherInfo);
if (error) {
return <p>Error!</p>;
}
if (!data) {
return <p>Loading..</p>;
}
return (
<>
<p>{data.temperature}</p>
<p>{data.humidity}</p>
</>
);
}
You will notice on the network tab that the prefetch
the query is executed first:
So if the user selects kolkata
chances of them seeing an indicator will be very less:
https://weather-blog-gpeypw5gj-varen90.vercel.app/
Full-fledged code:
Suspense
This is the other part of async coding that can get out of hand. Every time we make a remote call we have to deal with various states i.e.
Trigger the async call
Go into the loading state
If successful show the data
if error handle the error in a graceful way
Even if you even have a few remote data dependencies the code complexity grows.
The above states can be modeled as a union
to alleviate some of the pain:
function WeatherLoader({ location }: WeatherProps) {
const weatherData = useWeather(location);
switch (weatherData.status) {
case "error":
return <WeatherErrror />;
case "loading":
return <WeatherContentLoader />;
case "success":
return <Weather weatherInfo={weatherData.data} />;
default:
const _exceptionCase: never = weatherData;
return _exceptionCase;
}
}
A lot of frontend
code will look like this 😃 .
Now bear in mind that we are using the same getWeatherInfo
so whatever errors are thrown there will be handled as part of the “error” condition. We can tune the errors with some additional magic but I won’t go into detail about that here.
With Suspense
the above can be refactored into something like this:
function WeatherLoader({ location, retryLocation }: WeatherProps) {
return (
<ErrorBoundary
FallbackComponent={WeatherError}
resetKeys={[location]}
onReset={() => retryLocation(location)}
>
<Suspense fallback={<WeatherContentLoader />}>
<Weather location={location} />
</Suspense>
</ErrorBoundary>
);
}
To get this working with swr
all you need to do is:
const { data } = useSWR<BasicWeatherInfo>(location, getWeatherInfo, {
suspense: true,
});
Typically this would involve a lot more code
but that all is abstracted away for us when using libraries like this swr
.
To be honest suspense
is a lot more than just a data-fetching convenience. It literally suspends
any logic that the component is waiting on. Typically something asynchronous.
To accomplish this we have to throw
a promise
! (swr
does the heavy lifting for us here)
For now, it's not fully stable and has some gotchas -
https://reactjs.org/blog/2022/03/29/react-v18.html#suspense-in-data-frameworks
Nonetheless, once this becomes stable this will be really convenient. Since wherever there is an async data dependency we can wrap it inside Suspense
we can forget about handling all the scenarios that come with it.
Not only that we can have a nested Suspense
too. This enables granular control over what to show as fallback
.
Final Code using Suspense
:
Also using react-query
:
And the final app link:
https://weather-blog.vercel.app/
I hope you all found it useful. Thanks for reading!