Avi Aryan
Posted on February 3, 2020
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
.
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:
- When a request is sent to the API server endpoint for the first time, cache the response and then return it.
- The next time that same API request happens, use the cached response immediately.
- 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:
Adding Stale-while-refresh Caching
If we want to add stale-while-refresh caching to this, we need to update our app logic to:
- Cache a request's response uniquely after it is fetched for the first time.
- 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.
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.
- It has a random delay in responding.
- 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:
If you look closely, a few things happen.
- 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.
- 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.
- Now, we are on the second page. Again take note of the last user in the list.
- 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.
- 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:
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.
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.
- In the hook, you call an async function that returns a value after some time.
- 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
Posted on February 3, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 27, 2024