Data fetching patterns - balancing client and server with SWR and Next.js
Varenya Thyagaraj
Posted on January 13, 2023
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:
Lets 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:
GitHub - varenya/swr-blog at pure-client-side
Prefetch on Server
As you can see from the above screenshot I have chosen london
as the first location.
We could show the user a 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 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 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:
GitHub - varenya/swr-blog at pure-server-side
Let’s take a moment and talk about this in a bit more detail.
The demo app that I have created has few cities as options and we were able to leverage SWR
and Next.JS
to shift the remote call from client to server.
Now let’s take a step ahead here and assume that during a 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. Since 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 are able to reuse 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 same getWeatherInfo
function we get type-safety across client and server.
There are plenty more things this enables for us:
- If the call fails with an
error
on server we can send out an emptyfallback
and client will try the call! - We can set some timeout duration on 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
query is executed first:
So if user selects kolkata
chances of them seeing a indicator will be very less:
https://weather-blog-gpeypw5gj-varen90.vercel.app/
Full fledged code:
GitHub - varenya/swr-blog at prefetch-client-side
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 loading state
- If successful show data
- if error handle the error in a graceful way
Now these states have tendency to go out of hand. Even you even a few remote data dependencies the code complexity grows.
The above states can be modelled 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;
}
}
But this can get out of hand with multiple async data. In fact lot of frontend
code will look like this 😃 .
Now bear in mind that we are using same getWeatherInfo
so whatever error 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 for that here.
With Suspense
the above can be refactored into some thing 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 lot more code
but that all is abstracted away for us when using libraries like this swr
.
To be honest suspense
is lot more than just a data fetching convenience. It literally suspends
any logic that component is waiting for typically something asynchronous.
To accomplish this we actually have to throw
a promise
! (swr
does the heavy lifting for us here)
For now its not fully stable and has some gotchas -
https://reactjs.org/blog/2022/03/29/react-v18.html#suspense-in-data-frameworks
Final Code using Suspense
:
Also using react-query
:
GitHub - varenya/swr-blog at use-react-query
And the final app link:
https://weather-blog.vercel.app/
I hope you all found it useful. Thanks for reading!
Posted on January 13, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 4, 2024