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

Photo by Taylor Vick on Unsplash

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

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