DEV Community

Avi Aryan
Avi Aryan

Posted on

A guide to stale-while-revalidate data fetching with React Hooks

Leveraging the stale-while-revalidate HTTP Cache-Control extension is a popular technique. It involves using cached (stale) assets if they are found in the cache, and then revalidating the cache and updating it with a newer version of the asset if needed. Hence the name stale-while-revalidate.

How stale-while-revalidate Works

When a request is sent for the first time, it's cached by the browser. Then, when the same request is sent a second time, the cache is checked first. If the cache of that request is available and valid, the cache is returned as the response. Then, the cache is checked for staleness and is updated if found stale. The staleness of a cache is determined by the max-age value present in the Cache-Control header along with stale-while-revalidate.

A flowchart tracking stale-while-revalidate logic. It starts with a request. If it's not cached, or if the cache is invalid, the request is sent, the response is returned, and the cache is updated. Otherwise, the cached response is returned, after which the cache is checked for staleness. If it's stale, a request is sent and the cache is updated.

This allows for fast page loads, as cached assets are no longer in the critical path. They are loaded instantly. Also, since developers control how often the cache is used and updated, they can prevent browsers from showing overly outdated data to users.

Readers might be thinking that, if they can have the server use certain headers in its responses and let the browser take it from there, then what's the need of using React and Hooks for caching?

It turns out the server-and-browser approach only works well when we want to cache static content. What about using stale-while-revalidate for a dynamic API? It's hard to come up with good values for max-age and stale-while-revalidate in that case. Often, invalidating the cache and fetching a fresh response every time a request is sent will be the best option. This effectively means no caching at all. But with React and Hooks, we can do better.

stale-while-revalidate for the API

We noticed that HTTP's stale-while-revalidate doesn't work well with dynamic requests like API calls.

Even if we do end up using it, the browser will return either the cache or the fresh response, not both. This doesn't go well with an API request since we would like fresh responses every time a request is sent. However, waiting for fresh responses delays meaningful usability of the app.

So what do we do?

We implement a custom caching mechanism. Within that, we figure out a way to return both the cache and the fresh response. In the UI, the cached response is replaced with a fresh response when it's available. This is how the logic would look:

  1. When a request is sent to the API server endpoint for the first time, cache the response and then return it.
  2. The next time that same API request happens, use the cached response immediately.
  3. Then, send the request asynchronously to fetch a new response. When the response arrives, asynchronously propagate changes to the UI and update the cache.

This approach allows for instantaneous UI updates---because every API request is cached---but also eventual correctness in the UI since fresh response data is displayed as soon as it's available.

In this tutorial, we will see a step-by-step approach on how to implement this. We will call this approach stale-while-refresh since the UI is actually refreshed when it gets the fresh response.

Preparations: The API

To kickstart this tutorial, we will first need an API where we fetch data from. Luckily, there are a ton of mock API services available. For this tutorial, we will be using reqres.in.

The data we fetch is a list of users with a page query parameter. This is how the fetching code looks:

fetch("https://reqres.in/api/users?page=2")
  .then(res => res.json())
  .then(json => {
    console.log(json);
  });

Running this code gives us the following output. Here is a non-repetitive version of it:

{
  page: 2,
  per_page: 6,
  total: 12,
  total_pages: 2,
  data: [
    {
      id: 7,
      email: "michael.lawson@reqres.in",
      first_name: "Michael",
      last_name: "Lawson",
      avatar:
        "https://s3.amazonaws.com/uifaces/faces/twitter/follettkyle/128.jpg"
    },
    // 5 more items
  ]
}

You can see that this is like a real API. We have pagination in the response. The page query parameter is responsible for changing the page, and we have a total of two pages in the dataset.

Using the API in a React App

Let's see how we use the API in a React App. Once we know how to do it, we will figure out the caching part. We will be using a class to create our component. Here is the code:

import React from "react";
import PropTypes from "prop-types";

export default class Component extends React.Component {
  state = { users: [] };

  componentDidMount() {
    this.load();
  }

  load() {
    fetch(`https://reqres.in/api/users?page=${this.props.page}`)
      .then(res => res.json())
      .then(json => {
        this.setState({ users: json.data });
      });
  }

  componentDidUpdate(prevProps) {
    if (prevProps.page !== this.props.page) {
      this.load();
    }
  }

  render() {
    const users = this.state.users.map(user => (
      <p key={user.id}>
        <img
          src={user.avatar}
          alt={user.first_name}
          style={{ height: 24, width: 24 }}
        />
        {user.first_name} {user.last_name}
      </p>
    ));
    return <div>{users}</div>;
  }
}

Component.propTypes = {
  page: PropTypes.number.isRequired
};

Notice that we are getting the page value via props, as it often happens in real-world applications. Also, we have a componentDidUpdate function, which refetches the API data every time this.props.page changes.

At this point, it shows a list of six users because the API returns six items per page:

A preview of our React component prototype: six centered lines, each with a photo to the left of a name.

Adding Stale-while-refresh Caching

If we want to add stale-while-refresh caching to this, we need to update our app logic to:

  1. Cache a request's response uniquely after it is fetched for the first time.
  2. Return the cached response instantly if a request's cache is found. Then, send the request and return the fresh response asynchronously. Also, cache this response for the next time.

We can do this by having a global CACHE object that stores the cache uniquely. For uniqueness, we can use this.props.page value as a key in our CACHE object. Then, we simply code the algorithm mentioned above.

import apiFetch from "./apiFetch";

const CACHE = {};

export default class Component extends React.Component {
  state = { users: [] };

  componentDidMount() {
    this.load();
  }

  load() {
    if (CACHE[this.props.page] !== undefined) {
      this.setState({ users: CACHE[this.props.page] });
    }
    apiFetch(`https://reqres.in/api/users?page=${this.props.page}`).then(
      json => {
        CACHE[this.props.page] = json.data;
        this.setState({ users: json.data });
      }
    );
  }

  componentDidUpdate(prevProps) {
    if (prevProps.page !== this.props.page) {
      this.load();
    }
  }

  render() {
    // same render code as above
  }
}

Since the cache is returned as soon as it is found and since the new response data is returned by setState as well, this means we have seamless UI updates and no more waiting time on the app from the second request onward. This is perfect, and it is the stale-while-refresh method in a nutshell.

A flowchart tracking the stale-while-refresh logic. It starts with a request. If it's cached, setState() is called with the cached response. Either way, the request is sent, the cache is set, and setState() is called with a fresh response.

The apiFetch function here is nothing but a wrapper over fetch so that we can see the advantage of caching in real time. It does this by adding a random user to the list of users returned by the API request. It also adds a random delay to it:

export default async function apiFetch(...args) {
  await delay(Math.ceil(400 + Math.random() * 300));
  const res = await fetch(...args);
  const json = await res.json();
  json.data.push(getFakeUser());
  return json;
}

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

The getFakeUser() function here is responsible for creating a fake user object.

With these changes, our API is more real than before.

  1. It has a random delay in responding.
  2. It returns slightly different data for the same requests.

Given this, when we change the page prop passed to the Component from our main component, we can see the API caching in action. Try clicking the Toggle button once every few seconds in this CodeSandbox and you should see behavior like this:

An animation showing the toggling page with caching enabled. The specifics are described in the article.

If you look closely, a few things happen.

  1. When the app starts and is in its default state, we see a list of seven users. Take note of the last user on the list since it is the user that will be randomly modified the next time this request is sent.
  2. When we click on Toggle for the first time, it waits for a small amount of time (400-700ms) and then updates the list to the next page.
  3. Now, we are on the second page. Again take note of the last user in the list.
  4. Now, we click on Toggle again, and the app will go back to the first page. Notice that now the last entry is still the same user we noted down in Step 1, and then it later changes to the new (random) user. This is because, initially, the cache was being shown, and then the actual response kicked in.
  5. We click on Toggle again. The same phenomenon happens. The cached response from last time is loaded instantly, and then new data is fetched, and so we see the last entry update from what we noted down in Step 3.

This is it, the stale-while-refresh caching we were looking for. But this approach suffers from a code duplication issue. Let's see how it goes if we have another data-fetching component with caching. This component shows the items differently from our first component.

Adding Stale-while-refresh to Another Component

We can do this by simply copying the logic from the first component. Our second component shows a list of cats:

const CACHE = {};

export default class Component2 extends React.Component {
  state = { cats: [] };

  componentDidMount() {
    this.load();
  }

  load() {
    if (CACHE[this.props.page] !== undefined) {
      this.setState({ cats: CACHE[this.props.page] });
    }
    apiFetch(`https://reqres.in/api/cats?page=${this.props.page}`).then(
      json => {
        CACHE[this.props.page] = json.data;
        this.setState({ cats: json.data });
      }
    );
  }

  componentDidUpdate(prevProps) {
    if (prevProps.page !== this.props.page) {
      this.load();
    }
  }

  render() {
    const cats = this.state.cats.map(cat => (
      <p
        key={cat.id}
        style={{
          background: cat.color,
          padding: "4px",
          width: 240
        }}
      >
        {cat.name} (born {cat.year})
      </p>
    ));
    return <div>{cats}</div>;
  }
}

As you can see, the component logic involved here is pretty much the same as the first component. The only difference is in the requested endpoint and that it shows the list items differently.

Now, we show both these components side by side. You can see they behave similarly:

An animation showing toggling with two side-by-side components.

To achieve this result, we had to do a lot of code duplication. If we had multiple components like this, we would be duplicating too much code.

To solve it in a non-duplicating manner, we can have a Higher-order Component for fetching and caching data and passing it down as props. It's not ideal but it will work. But if we had to do multiple requests in a single component, having multiple Higher-order Components would get ugly really quickly.

Then, we have the render props pattern, which is probably the best way to do this in class components. It works perfectly, but then again, it is prone to "wrapper hell" and requires us to bind the current context at times. This is not a great developer experience and can lead to frustration and bugs.

This is where React Hooks save the day. They allow us to box component logic in a reusable container so that we can use it at multiple places. React Hooks were introduced in React 16.8 and they only work with function components. Before we get to React cache control, let's first see how we do simple data fetching in function components.

API Data Fetching in Function Components

To fetch API data in function components, we use useState and useEffect hooks.

useState is analogous to class components' state and setState. We use this hook to have atomic containers of state inside a function component.

useEffect is a lifecycle hook, and you can think of it as a combination of componentDidMount, componentDidUpdate, and componentWillUnmount. The second parameter passed to useEffect is called a dependency array. When the dependency array changes, the callback passed as the first argument to useEffect is run again.

Here is how we will use these hooks to implement data fetching:

import React, { useState, useEffect } from "react";

export default function Component({ page }) {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetch(`https://reqres.in/api/users?page=${page}`)
      .then(res => res.json())
      .then(json => {
        setUsers(json.data);
      });
  }, [page]);

  const usersDOM = users.map(user => (
    <p key={user.id}>
      <img
        src={user.avatar}
        alt={user.first_name}
        style={{ height: 24, width: 24 }}
      />
      {user.first_name} {user.last_name}
    </p>
  ));

  return <div>{usersDOM}</div>;
}

By specifying page as a dependency to useEffect, we instruct React to run our useEffect callback every time page is changed. This is just like componentDidUpdate. Also, useEffect always runs the first time so it works like componentDidMount too.

Stale-while-refresh in Function Components

We know that useEffect is similar to component lifecycle methods. So we can modify the callback function passed to it to create the stale-while-refresh caching we had in class components. Everything remains the same except the useEffect hook.

const CACHE = {};

export default function Component({ page }) {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    if (CACHE[page] !== undefined) {
      setUsers(CACHE[page]);
    }
    apiFetch(`https://reqres.in/api/users?page=${page}`).then(json => {
      CACHE[page] = json.data;
      setUsers(json.data);
    });
  }, [page]);

  // ... create usersDOM from users

  return <div>{usersDOM}</div>;
}

Thus, we have stale-while-refresh caching working in a function component.

We can do the same for the second component, that is, convert it to function and implement stale-while-refresh caching. The result will be identical to what we had in classes.

But that's not any better than class components, is it? So let's see how we can use the power of a custom hook to create modular stale-while-refresh logic that we can use across multiple components.

A Custom Stale-while-refresh Hook

First, let's narrow down the logic we want to move into a custom hook. If you look at the previous code, you know it's the useState and useEffect part. More specifically, this is the logic we want to modularize.

const [users, setUsers] = useState([]);

useEffect(() => {
  if (CACHE[page] !== undefined) {
    setUsers(CACHE[page]);
  }
  apiFetch(`https://reqres.in/api/users?page=${page}`).then(json => {
    CACHE[page] = json.data;
    setUsers(json.data);
  });
}, [page]);

Since we have to make it generic, we will have to make the URL dynamic. So we need to have url as an argument. We will need to update the caching logic, too, since multiple requests can have the same page value. Luckily, when page is included with the endpoint URL, it yields a unique value for every unique request. So we can just use the entire URL as a key for caching:

const [data, setData] = useState([]);

useEffect(() => {
  if (CACHE[url] !== undefined) {
    setData(CACHE[url]);
  }
  apiFetch(url).then(json => {
    CACHE[url] = json.data;
    setData(json.data);
  });
}, [url]);

That's pretty much it. After wrapping it inside a function, we will have our custom hook. Have a look below.

const CACHE = {};

export default function useStaleRefresh(url, defaultValue = []) {
  const [data, setData] = useState(defaultValue);

  useEffect(() => {
    // cacheID is how a cache is identified against a unique request
    const cacheID = url;
    // look in cache and set response if present
    if (CACHE[cacheID] !== undefined) {
      setData(CACHE[cacheID]);
    }
    // fetch new data
    apiFetch(url).then(newData => {
      CACHE[cacheID] = newData.data;
      setData(newData.data);
    });
  }, [url]);

  return data;
}

Notice we have added another argument called defaultValue to it. The default value of an API call can be different if you use this hook in multiple components. That's why we have made it customizable.

The same can be done for the data key in the newData object. If your custom hook returns a variety of data, you might want to just return newData and not newData.data and handle that traversal on the component side.

Now that we have our custom hook, which does the heavy lifting of stale-while-refresh caching, here is how we plug it into our components. Notice the sheer amount of code we were able to reduce. Our entire component is now just three statements. That's a big win.

import useStaleRefresh from "./useStaleRefresh";

export default function Component({ page }) {
  const users = useStaleRefresh(`https://reqres.in/api/users?page=${page}`, []);

  const usersDOM = users.map(user => (
    <p key={user.id}>
      <img
        src={user.avatar}
        alt={user.first_name}
        style={{ height: 24, width: 24 }}
      />
      {user.first_name} {user.last_name}
    </p>
  ));

  return <div>{usersDOM}</div>;
}

We can do the same for the second component. It will look like this:

export default function Component2({ page }) {
  const cats = useStaleRefresh(`https://reqres.in/api/cats?page=${page}`, []);

  // ... create catsDOM from cats

  return <div>{catsDOM}</div>;
}

It's easy to see how much boilerplate code we can save if we use this hook. The code looks better as well. If you want to see the entire app in action, head over to this CodeSandbox.

Adding a Loading Indicator to useStaleRefresh

Now that we have the basics on point, we can add more features to our custom hook. For example, we can add an isLoading value in the hook that is true whenever a unique request is sent and we don't have any cache to show in the meanwhile.

We do this by having a separate state for isLoading and setting it according to the state of the hook. That is, when no cached web content is available, we set it to true, otherwise we set it to false.

Here is the updated hook:

export default function useStaleRefresh(url, defaultValue = []) {
  const [data, setData] = useState(defaultValue);
  const [isLoading, setLoading] = useState(true);

  useEffect(() => {
    // cacheID is how a cache is identified against a unique request
    const cacheID = url;
    // look in cache and set response if present
    if (CACHE[cacheID] !== undefined) {
      setData(CACHE[cacheID]);
      setLoading(false);
    } else {
      // else make sure loading set to true
      setLoading(true);
    }
    // fetch new data
    apiFetch(url).then(newData => {
      CACHE[cacheID] = newData.data;
      setData(newData.data);
      setLoading(false);
    });
  }, [url]);

  return [data, isLoading];
}

We can now use the new isLoading value in our components.

export default function Component({ page }) {
  const [users, isLoading] = useStaleRefresh(
    `https://reqres.in/api/users?page=${page}`,
    []
  );

  if (isLoading) {
    return <div>Loading</div>;
  }

  // ... create usersDOM from users

  return <div>{usersDOM}</div>;
}

Notice that with that done, you see "Loading" text when a unique request is sent for the first time and no cache is present.

An animation showing the component with a loading indicator implemented.

Making useStaleRefresh Support Any async Function

We can make our custom hook even more powerful by making it support any async function rather than just GET network requests. The basic idea behind it will remain the same.

  1. In the hook, you call an async function that returns a value after some time.
  2. Each unique call to an async function is properly cached.

A simple concatenation of function.name and arguments will work as a cache key for our use case. Using that, this is how our hook will look:

import { useState, useEffect, useRef } from "react";
import isEqual from "lodash/isEqual";
const CACHE = {};

export default function useStaleRefresh(fn, args, defaultValue = []) {
  const prevArgs = useRef(null);
  const [data, setData] = useState(defaultValue);
  const [isLoading, setLoading] = useState(true);

  useEffect(() => {
    // args is an object so deep compare to rule out false changes
    if (isEqual(args, prevArgs.current)) {
      return;
    }
    // cacheID is how a cache is identified against a unique request
    const cacheID = hashArgs(fn.name, ...args);
    // look in cache and set response if present
    if (CACHE[cacheID] !== undefined) {
      setData(CACHE[cacheID]);
      setLoading(false);
    } else {
      // else make sure loading set to true
      setLoading(true);
    }
    // fetch new data
    fn(...args).then(newData => {
      CACHE[cacheID] = newData;
      setData(newData);
      setLoading(false);
    });
  }, [args, fn]);

  useEffect(() => {
    prevArgs.current = args;
  });

  return [data, isLoading];
}

function hashArgs(...args) {
  return args.reduce((acc, arg) => stringify(arg) + ":" + acc, "");
}

function stringify(val) {
  return typeof val === "object" ? JSON.stringify(val) : String(val);
}

As you can see, we are using a combination of function name and its stringified arguments to uniquely identify a function call and thus cache it. This works for our simple app, but this algorithm is prone to collisions and slow comparisons. (With unserializable arguments, it won't work at all.) So for real-world apps, a proper hashing algorithm is more appropriate.

Another thing to note here is the use of useRef. useRef is used to persist data through the entire lifecycle of the enclosing component. Since args is an array---which is an object in JavaScript---every re-render of the component using the hook causes the args reference pointer to change. But args is part of the dependency list in our first useEffect. So args changing can make our useEffect run even when nothing changed. To counter that, we do a deep-comparison between old and current args using isEqual and only let the useEffect callback run if args actually changed.

Now, we can use this new useStaleRefresh hook as follows. Notice the change in defaultValue here. Since it's a general-purpose hook, we are not relying on our hook to return the data key in the response object.

export default function Component({ page }) {
  const [users, isLoading] = useStaleRefresh(
    apiFetch,
    [`https://reqres.in/api/users?page=${page}`],
    { data: [] }
  );

  if (isLoading) {
    return <div>Loading</div>;
  }

  const usersDOM = users.data.map(user => (
    <p key={user.id}>
      <img
        src={user.avatar}
        alt={user.first_name}
        style={{ height: 24, width: 24 }}
      />
      {user.first_name} {user.last_name}
    </p>
  ));

  return <div>{usersDOM}</div>;
}

You can find the entire code in this CodeSandbox.

Conclusion

The useStaleRefresh hook we created in this article is a proof of concept that shows what's possible with React Hooks. Try to play with the code and see if you can fit it into your application.

Alternatively, you can also try leveraging stale-while-revalidate via a popular, well-maintained open-source library like swr or react-query. Both are powerful libraries and support a host of features that help with API requests.

React Hooks are a game-changer. They allow us to share component logic elegantly. This was not possible before because the component state, lifecycle methods, and rendering were all packaged into one entity: class components. Now, we can have different modules for all of them. This is great for composability and writing better code. I am using function components and hooks for all the new React code I write, and I highly recommend this to all React developers.


This article was first posted on Toptal's Engineering Blog

Top comments (0)