← Back to Blog

Stale-while-revalidate beyond HTTP requests

Natalie Marleny
@nataliemarleny
a month ago

While implementing features in the Indent dashboard, we heavily use the localStorage browser API. A convenient way to use the localStorage in React is the useLocalStorage hook from usehooks.com. Below is a simplified example of the pattern that often occurs in our code while using the useLocalStorage hook:

const Component1 = () => {
const [value, setValue] = useLocalStorage('key1')
return <div>Component1 {value}</div>;
};
const Component2 = () => {
const [value, setValue] = useLocalStorage('key1')
return <div>Component2 {value}</div>;
};
const Page = () => (
<>
<Component1 />
<Component2 />
</>;
);

In the above example we have two independent components utilizing the useLocalStorage hook with the same key. The original implementation of the useLocalStorage has a few drawbacks when used in this manner:

  • Many re-renders - Each use of the useLocalStorage creates a dedicated state variable with useState. Each of these states is completely separate from one another. If all of these states need to be updated to a new value, one re-render is going to occur for each of the updates.
  • Inconsistent state - Each use of the useLocalStorage creates a separate setValue function that updates only the piece of state that it is associated with. This means that if the setValue is called within one of these components the other component wouldn't reflect the correct value of the localStorage entry until the page is refreshed.
  • Potential race condition - Each use of the useLocalStorage independently reads from the localStorage when the component using useLocalStorage is mounted. This means that different components on the same page could potentially read different values from the localStorage. The result could be an inconsistent UI.

Each read from the localStorage happens on the component mount. Each useLocalStorage reads from the localStorage separately.Each read from the localStorage happens on the component mount. Each useLocalStorage reads from the localStorage separately.

Initially I was using the swr JavaScript library (named after the stale-while-revalidate pattern) only for HTTP caching, and then I came to the realisation that swr is so much more cool and general purpose to only be a library for caching HTTP requests. So I got the idea to write a useLocalStorage implementation which has a more optimal behaviour using the swr library and here's the result:

import { useCallback, useMemo } from 'react'
import useSWR, { cache } from 'swr'
const localStorageFetcher = async (_fetcherName: string, key: string) => {
const item = window.localStorage.getItem(key)
// An Error occurring in the fetcher is gracefully handled by SWR
if (item == undefined) throw new Error()
return JSON.parse(item)
}
export function useLocalStorage<T>(
key: string,
initialValue?: T | (() => T)
): any[] {
const swrKey = useMemo(() => ['localStorageFetcher', key], [key])
const initialData = useMemo(
() => (initialValue instanceof Function ? initialValue() : initialValue),
[initialValue]
)
const { data: storedValue, mutate } = useSWR(swrKey, localStorageFetcher, {
initialData,
// Read from the localStorage only if the value is not in the cache already.
revalidateOnMount: !cache.has(swrKey),
// Disable refetching from the localStorage. After the initialization from
// the localStorage, the value is not re-read until the page is re-loaded.
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenHidden: false,
refreshWhenOffline: false,
shouldRetryOnError: false,
refreshInterval: 0
})
const setStoredValue = useCallback(
(value: T | ((c: T) => T)) => {
mutate(
(currentStoredValue: any) => {
const valueToStore =
value instanceof Function ? value(currentStoredValue) : value
if (valueToStore === currentStoredValue) return currentStoredValue
window.localStorage.setItem(key, JSON.stringify(valueToStore))
return valueToStore
},
false // don't refetch from the localStorage, just update the state
)
},
[mutate, key]
)
return [storedValue, setStoredValue]
}

The new useLocalStorage has the same API, but:

  • it reads from the localStorage only once per page load for the given localStorage key, regardless of the number of times it is called
  • it causes only a single additional re-render after the value has been loaded, regardless of the number of times it is called
  • when the new components which use the useLocalStorage hook enter the page with the same key, they don't read from the localStorage again, but use the swr cached value instead
  • changing the value in any of the components, using the setValue function causes the immediate change in all other components using useLocalStorage with that key. No page refresh is necessary.

All of this means that the performance and the robustness of the UI is going to be improved.

Instead of having a separate piece of state for every invocation of the useLocalStorage hook, we have a single piece of state that all of the useLocalStorage invocations refer to. Only the first component that mounts reads from the localStorage. Every subsequent component reads from the SWR Cache.Instead of having a separate piece of state for every invocation of the useLocalStorage hook, we have a single piece of state that all of the useLocalStorage invocations refer to. Only the first component that mounts reads from the localStorage. Every subsequent component reads from the SWR Cache.

After implementing this version of the useLocalStorage hook I wasn't surprised to discover that others have attempted to do the same. Most notably the zydalabs/swr-internal-state library contains a slightly different implementation of useLocalStorage using swr.