Data fetching patterns - balancing client and server with SWR and Next.js

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:

Sample App Image 1

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..):

Sample App Image 2

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 empty fallback 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:

Sample App Image 3

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 :

GitHub - varenya/swr-blog

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!