A notice to our customers: Microsoft are experiencing disruption related to several of their M365 services that could impact your system. We are carefully monitoring the situation. You can be kept updated via Microsoft's service page.
×

Persisting your React Query cache in local storage

24 March 2023 By Marcin Prystupa

Client-side persistence using React Query and persistentQueryClient

Continuing the topic started in my previous blog post, I want to talk about React Query once more. This time, however, we will explore a rather unorthodox way of using it: the usual use case of React Query is to fetch data and cache them in the browser; but what if we’re providing our own data on the front-end and want to cache them without implementing that ourselves?

TL;DR

React Query, in tandem with the Web Storage API, can be used to cache some front-end data in localStorage, without need to keep them in the database. This way, you can very simply store parts of your front-end data, as well as results of your server requests (reducing unnecessary server calls even further).

Taking shortcuts

At some point after introducing React Query to our client’s project, we were entrusted with a new task. The product was to be extended with a new filtering feature, that allowed users to filter their items by types. Simple enough! However, there was a small catch that we underestimated: that filter had to persist between sessions, making this a bit harder than expected, but still not very hard, right? We just had to store it somewhere in the database, push it each time user changes the filter and load it back on opening new session… If that sounds to you like a lot of requests flying around, it’s because it is! And all of that for a feature that is completely tied to the client app, surely not something belonging in the backend database.

So, I started to think: we already have cache provided by React Query, maybe we would be able to utilize it to store our data without fetching anything from the server? Obviously that wouldn’t resolve the persistency requirement, but we had to start somewhere! With that in mind, we started implementing a new hook, called useStorageQuery. We just created it as if it was calling the data from the server, without it really doing any call at the moment. It looked almost exactly the same as any other query hook we used before:

import { useQuery } from "@tanstack/react-query";

export const useStorageQuery = <T>(queryKey: string) => {
  const { data, isLoading, isError, error } = useQuery({
    queryKey: [queryKey],
    queryFn: (): T | undefined => {
      return undefined;
    },
    cacheTime: Infinity,
  });

  return { data, isLoading, isError, error };
};

The code is pretty straightforward. We are creating a new query using the useQuery hook from React Query, we provide the query key (you can think of it as an identifier of the query data), and we set the function that will provide the data for this particular query - usually it would be server data fetch. Now, we do not expect any data to be fetched from the server, so we can safely return undefined as our query function. Why? Because once we cache the value, this function will never be called again, thanks to us setting cacheTime parameter to Infinity. This way the cache for this particular query key will never expire, unless we manually invalidate it.

With that being said, all this query will return is undefined, so we need a way to set our data within the cache. In order to do that, we have to somehow directly manipulate the data within React Query cache, and thankfully we can use queryClient to do just that. We also can set the initial data in storage query by using initialData field in useQuery options. However, if we just pass data in there, it will replace our current query value each time we rerender our component. To fix that, we can check what we have in the cache first, and if it’s empty we can just return our initial data. After that, our hook looks as follows:

import { useQuery, useQueryClient } from "@tanstack/react-query";

export const useStorageQuery = <T>(queryKey: string, initialData?: T) => {
  const queryClient = useQueryClient();

  const { data, isLoading, isError, error } = useQuery({
    queryKey: [queryKey],
    queryFn: (): T | undefined => {
      return undefined;
    },
    cacheTime: Infinity,
    initialData: () => {
      // Check if we have anything in cache and return that, otherwise get initial data
      const cachedData = queryClient.getQueryData<T | undefined>([queryKey]);
      if (cachedData) {
        return cachedData;
      }

      return initialData;
    },
  });

  const setData = (data: T) => {
    queryClient.setQueryData([queryKey], () => data);
  };

  return { data, setData, isLoading, isError, error };
};

Looks pretty good so far! Let’s add some code to the basic App.tsx component and see whether it works as expected. I’ll store an initial array of strings in here and see whether we can retrieve those values as data field from useStorageQuery hook. Here’s App.tsx component after using our hook:

import "./App.css";
import { useStorageQuery } from "./hooks/useStorageQuery";

export const App = () => {
  const { data: cachedStrings } = useStorageQuery("strings", [
    "here",
    "are",
    "some",
    "strings",
  ]);

  return <div className="App">{cachedStrings?.join(" ")}</div>;
};

However, when running our application we are greeted with white screen and following error Uncaught Error: No QueryClient set, use QueryClientProvider to set one. That’s because we need to wrap our App.tsx component with QueryClientProvider first, if we want to use queryClient:

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import "./index.css";
import reportWebVitals from "./reportWebVitals";

const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);

const client = new QueryClient();

root.render(
  <React.StrictMode>
    <QueryClientProvider client={client}>
      <App />
    </QueryClientProvider>
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

After adding client provider, our app runs as expected and we are greeted with somewhat boring white screen with our data listed out:

Displaying data from React Query

So, let’s add a button that will replace data in our cache with some other strings, that will be inserted into React Query cache. We will move this code into its own component as well, just so we will be able to demonstrate that destroying the component does not remove data from the cache and it’s being kept safe. Here’s StorageComponentExample.tsx, containing the code related to changing query data:

import { useCallback } from "react";
import { useStorageQuery } from "./hooks/useStorageQuery";

export const StorageComponentExample = () => {
  const { data: cachedStrings, setData } = useStorageQuery("strings", [
    "here",
    "are",
    "some",
    "strings",
  ]);

  const updateCache = useCallback(() => {
    setData(["we", "updated", "our", "cache!"]);
  }, [setData]);

  return (
    <div>
      {cachedStrings?.join(" ")} <br />
      <button onClick={updateCache}>Update cache!</button>
    </div>
  );
};

Here’s the updated App.tsx, containing a button that shows and hides the component above, to make sure that it is destroyed and removed from the DOM:

import { useState } from "react";
import "./App.css";
import { StorageComponentExample } from "./StorageComponentExample";

export const App = () => {
  const [showStorageComponent, setShowStorageComponent] =
    useState<boolean>(true);

  return (
    <div className="App">
      {showStorageComponent && <StorageComponentExample />}
      <button onClick={() => setShowStorageComponent((val) => !val)}>
        {`${showStorageComponent ? "Hide" : "Show"} storage component`}
      </button>
    </div>
  );
};

Updated app with button to replace displayed data

Now the usual React Query magic happens - if we click on the ‘Update cache!’ button, the displayed text will change to ‘we updated our cache!’, as expected:

Replaced data after clicking on a button

What’s more, closing and reopening the component using Hide storage component button keeps the data, as it’s cached in app memory. If we do some changes to our code, the hot reload feature will update our app without dropping this data as well. In fact, if we implemented some routing with different tabs and pages, this data would still be persisted. As soon as we refresh the page however, it reverts back to the default values - this is the persistence problem I mentioned at the beginning!

Introducing persistence

In order to persist this data outside of the application, we need to move our React Query cache outside of in-memory storage and into something less volatile. Browser’s local storage sounds like a great place and thankfully, there is already a plugin to React Query that does exactly that: stores query in local storage. It’s called persistQueryClient, check it out here!

So, let’s modify our code to add this feature to our app. First, we have to update our React Query setup in index.tsx to use persistQueryClient. After adding the required libraries, first step is to create a ‘persister’, which will be joined with our previously created query client to create a persistent query client.

We move from this code:

const client = new QueryClient();

To something like this:

const client = new QueryClient({
  defaultOptions: {
    queries: {
      cacheTime: 1000 * 60 * 60 * 24, // 24 hours
    },
  },
});

const localStoragePersister = createSyncStoragePersister({
  storage: window.localStorage,
});

persistQueryClient({
  queryClient: client,
  persister: localStoragePersister,
});

Let’s walk through this code step by step:

  1. First, we changed our QueryClient to use the default option of cacheTime. This way, we ensured that all our cached entries will be deemed as stale and updated every 24 hours. If we wanted to, we could set it to Infinity, but forgetting to invalidate some queries could result with data being considered always fresh and thus, never being updated.
  2. Next, we use the function createSyncStoragePersister from the newly installed library to persist our queries in local storage. A great thing about this function is that it can take any other type of storage as well, so you are not bound to using local storage if you don’t want to. Finally, we use our persister to actually tell React Query to persist its query client - we pass both client and localStoragePersister to the method.

And that’s basically it! No change to the code is required whatsoever, and if we go back to our example application, click on Update cache!, close the tab and reopen it, the data stays updated, which is awesome. If you wanted to see for yourself how the data is stored now, you can go to local storage by opening DevTools>Application tab and selecting your app’s host name in Local Storage dropdown list. There should be entry called REACT_QUERY_OFFLINE_CACHE and it contains a JSON-formatted value. For my example, it looks like this:

{
  "buster": "",
  "timestamp": 1677480497631,
  "clientState": {
    "mutations": [],
    "queries": [
      {
        "state": {
          "data": ["we", "updated", "our", "cache!"],
          "dataUpdateCount": 2,
          "dataUpdatedAt": 1677480497631,
          "error": null,
          "errorUpdateCount": 7,
          "errorUpdatedAt": 1677480497562,
          "fetchFailureCount": 1,
          "fetchFailureReason": {},
          "fetchMeta": {},
          "isInvalidated": false,
          "status": "success",
          "fetchStatus": "idle"
        },
        "queryKey": ["strings"],
        "queryHash": "[\"strings\"]"
      }
    ]
  }
}

By removing this data, we can revert our app to its initial state.

With great power comes great responsibility

Following the steps outlined above will result in all of the queries data being persisted in local storage, which means you have to be extra careful to not store any sensitive data in here. That makes using persister a pretty definitive option - you either store all of your data or none. I’d love to be able to decide which options are persisted and which are not, but as far as I looked through the documentation, I couldn’t find anything like that. In theory, we could still implement our own option to write query results to local storage, but our queries did not contain anything scary, so it was just easier to go with the existing functionality. If we will ever have to fetch some sensitive data, we just won’t pull it through React Query.

Another thing to consider is different browsers have different capacities for localStorage. Generally speaking, it is a great option to store your usual everyday data, but considering the typical storage limit to be around 5-10MB, local storage is probably not the best solution to store huge datasets and proper fallback should be implemented in case your data gets too big. You also have to remember, that although all major browsers support localStorage, there is a possibility that it will be disabled. However, if those limits are problematic for you in any way, there are other kinds of storages available as well, and persister is happy to use anything that fulfils the usual Storage interface.

Conclusions

It seems like by using existing libraries, we were able to achieve exactly what client requested us to do without doing much of our own implementation! This way we don’t have to deal with persisting data ourselves, while keeping all of the benefits provided by React Query. It will not be a perfect solution for everyone, but I’m sure there’s a room for improvement.