The Most Advanced React Book Buy Now

Tanstack Query Is Not Just For API Requests - It's an Async State Manager!

undefined article image

Tanstack Query is one of the most popular solutions for handling API requests, pending and error states, and more. It provides a lot of useful functionality, such as background refetching, query deduplication and invalidation, or handling paginated and infinite queries. What's more, it offers adapters for multiple frameworks, so it can be used with React, Vue and a few other frameworks. However, did you know that Tanstack Query isn't just for API requests? Technically, it is an async state manager. In this article, we will cover a few interesting use cases for using Tanstack Query that do not involve API requests:

  • Browser APIs
  • IndexedDB
  • Async Libraries
  • Web Workers

Tanstack Query and Browser APIs

Browsers offer a lot of built-in Web APIs. Some of them are synchronous, such as local storage, or callback-based, like Page Visibility API. There are also asynchronous APIs. A good example is the Permissions API. Most of the time, Tanstack Query is used to perform API requests. The useQuery method is used to fetch data, whilst useMutation is used to send data from the client to the server. However, Tanstack Query is a library that can handle any asynchronous operations. Here's an example of how Tanstack Query can be used to request permission to access the camera.

Loading code example...

The key functionality lies in the useCameraPermission hook.

const useCameraPermission = () => {
  const {
    data: isCameraPermissionEnabled,
    isLoading: isCheckingCameraPermission,
    isSuccess: isCheckingCameraPermissionSuccess,
    refetch: askForCameraPermission,
  } = useQuery({
    queryKey: ["permissions-api"],
    queryFn: async () => {
      try {
        await navigator.mediaDevices.getUserMedia({
          video: true,
        });
        return true;
      } catch (error) {
        console.log("ERROR");
        console.error(error);
        return false;
      }
    },
    enabled: false,
    staleTime: Infinity,
    retry: 0,
  });
}

Instead of performing an API request in the queryFn, we use the navigator.mediaDevices.getUserMedia method to ask for permission to use the user's camera.

Tanstack Query executes queries immediately and is configured to re-execute the query callback after a certain time passes or if a user focuses the document. These are good defaults, but it's not something we want for this scenario. That's why we:

  • disable the query using enabled: false
  • set staleTime: Infinity to prevent automated re-fetching
  • set retry: 0 to prevent retries

Note that the enabled: false config does not affect the refetch method returned by useQuery. Therefore, when we call askForCameraPermission, which is the renamed refetch, Tanstack Query will execute the queryFn callback.

That's how Tanstack Query can be used with async Web APIs. Next, let's have a look at how it can be used with IndexedDB.

Tanstack Query and IndexedDB

Local storage is one of the most common solutions that is used to persist data locally. However, did you know that browsers offer a built-in database? IndexedDB is a low-level API for client-side storage that can hold a significant amount of structured data. Compared to local storage, which has a restriction of up to 10 MB, IndexedDB volume is mostly restricted by the user's available disk space. Therefore, it can hold much more data. Tanstack Query can be used to handle data fetching and submission to IndexedDB. The interactive example below has a simple application that persists a list of fruits in IndexedDB.

Loading code example...

We use idb, a wrapper library around IndexedDB, which makes it easier to deal with IndexedDB. We establish a connection with the database in the useIndexedDb hook.

import { useQuery } from "@tanstack/react-query";
import { type OpenDBCallbacks, type DBSchema, openDB } from "idb";

export const useIndexedDb = <DBTypes extends DBSchema | unknown = unknown>(
  name: string,
  version?: number,
  config?: OpenDBCallbacks<DBTypes>
) => {
  const {
    data: indexedDb,
    isLoading: isConnecting,
    isSuccess: isDbReady,
  } = useQuery({
    queryKey: ["indexed-db"],
    queryFn: async () => {
      try {
        const db = await openDB(name, version, config);
        return db;
      } catch (error) {
        console.error(error);
        throw error;
      }
    },
    staleTime: Infinity
  });

  return {
    indexedDb,
    isConnecting,
    isDbReady,
  };
};

The indexedDb instance is returned by useIndexedDb and used in the useFruitsStore hook.

The useFruitsStore hook has queries and mutations to fetch, add and delete fruits.

const { data: fruits, isLoading: isFruitsLoading } = useQuery<
  Array<{
    fruitid: string;
    name: string;
  }>
>({
  queryKey: ["fruits-query"],
  queryFn: async () => {
    try {
      if (!indexedDb) {
        return [];
      }
      const fruits = await indexedDb.getAll(FRUITS_STORE);
      return fruits || [];
    } catch (error) {
      console.error(error);
      throw error;
    }
  },
  enabled: isDbReady,
});

The query to fetch fruits is enabled only when the IndexedDB instance is ready (enabled: isDbReady). The fruits are fetched inside of the query callback: const fruits = await indexedDb.getAll(FRUITS_STORE);. If there is any fruits data, it is returned, or we default to an empty array.

Next, we have two mutations. One to add a fruit and another one to delete it.

const { mutateAsync: addFruit, isPending: isAddFruitPending } = useMutation({
  mutationFn: async (fruit: string) => {
    if (!indexedDb) {
      throw new Error("FRUITS STORE NOT READY");
    }
    await indexedDb.add(FRUITS_STORE, {
      fruitid: Math.random(),
      name: fruit,
    });
  },
  onSuccess() {
    queryClient.invalidateQueries({
      queryKey: ["fruits-query"],
    });
  },
});

const { mutateAsync: deleteFruit, isPending: isDeleteFruitPending } =
  useMutation({
    mutationFn: async (fruitId: string) => {
      if (!indexedDb) {
        throw new Error("FRUITS STORE NOT READY");
      }
      await indexedDb.delete(FRUITS_STORE, fruitId);
    },
    onSuccess() {
      queryClient.invalidateQueries({
        queryKey: ["fruits-query"],
      });
    },
  });

All the hooks and values returned by the useFruitsStore hook are used in the IndexedDb.ts file.

function IndexedDb() {
  const [fruit, setFruit] = useState("");
  const {
    isLoading,
    isReady,
    addFruit,
    fruits,
    deleteFruit,
    isAddFruitPending,
  } = useFruitsStore();
  return (
    <div>
      {/* jsx */}
    </div>
  )
}

React - The Road to Enterprise cover book

React - The Road To Enterprise

Do you want to know how to create scalable and maintainable React apps with architecture that actually works?

Find out more

Tanstack Query and Async Libraries

We will use localforage to demonstrate how to utilise Tanstack Query with async libraries. The localforage library is a fast and simple storage wrapper around IndexedDB and WebSQL with a localStorage-like API. If a browser does not support IndexedDB or WebSQL, it will fallback to using localStorage. We can use it with Tanstack Query to fetch and write data to whichever storage solution is available in the current browser. The interactive example below showcases the same functionality as the example in the previous section, but this time using localforage instead of idb.

Loading code example...

In the useFruitsLocalStore hook we have a query to read the fruits data using localforage and two mutations for adding and deleting fruits.

const { data: fruits = [] } = useQuery({
  queryKey: ["fruits-query"],
  queryFn: async () => {
    const fruits = await localforage.getItem<Array<Fruit>>("fruits");
    return fruits || [];
  },
});

The read fruits query is very simple, as it just reads fruits data and returns it. If there is no data in the storage, then it returns an empty array.

In the add fruit mutation, we use the localforage.setItem method to combine current fruits with new one.

const { mutateAsync: addFruit, isPending: isAddFruitPending } = useMutation({
  mutationFn: async (fruit: string) => {
    await localforage.setItem("fruits", [
      ...fruits,
      {
        fruitid: Math.random(),
        name: fruit,
      },
    ]);
  },
  onSuccess() {
    queryClient.invalidateQueries({
      queryKey: ["fruits-query"],
    });
  },
});

Upon successful submission, we invalidate the fruits-query, so Tanstack Query refetches the latest data from the storage.

Last but not least, in the delete fruit mutation, we filter out a fruit by its ID and invalidate fruits-query in the onSuccess callback.

const { mutateAsync: deleteFruit, isPending: isDeleteFruitPending } =
  useMutation({
    mutationFn: async (fruitid: string) => {
      await localforage.setItem(
        "fruits",
        fruits.filter(fruit => fruit.fruitid !== fruitid)
      );
    },
    onSuccess() {
      queryClient.invalidateQueries({
        queryKey: ["fruits-query"],
      });
    },
  });

Tanstack Query and Web Workers

Here's another interesting use case for Tanstack Query. Have you ever tried using Tanstack Query with Web Workers? This can be a bit tricky, as Web Worker's API is not promise-based. Messages to the worker can be sent using worker.postMessage method. We can subscribe to messages from a worker using the worker.onmessage handler or we can register one using worker.addEventListener(). So, how can we combine Web Workers with Tanstack Query? How about promisifying a Web Worker? Fortunately, there is a library called Comlink that can do just that.

The interactive example below showcases how to combine Tanstack Query with the Comlink library to sort a list of users in a Web Worker.

Loading code example...

You can click on the Ascending and Descending buttons to change how users are sorted.

In the WebWorker.ts file, we export contents for the worker script. The object that is exposed via comlink has one method - sortUsers. It sorts users array by email in ascending or descending order. The worker script is first used to create a blob.

const workerBlob = new Blob([workerString], {
  type: "text/javascript",
});

Next, we create a URL for the worker blob.

const workerUrl = URL.createObjectURL(workerBlob);

The worker URL is then passed to the Comlink.wrap method.

const worker = Comlink.wrap<{
  sortUsers(data: { sortBy: SortBy; users: Array<User> }): Array<User>;
}>(new Worker(workerUrl));

Finally, we call the worker's sortUsers method inside of the queryFn callback.

const { data: users = [] } = useQuery({
  queryKey: ["users", sortBy],
  queryFn: async () => {
    return worker.sortUsers({
      users: usersData,
      sortBy,
    });
  },
});

That's how we can use Tanstack Query to communicate with a Web Worker.

Summary

Tanstack Query is a powerful async state manager library. Even though it is used most commonly for handling server state via API requests, it can be used for much more than that. In this tutorial, we have covered how to use it to communicate with async Web APIs, libraries and even workers. Did you find any interesting use cases for Tanstack Query? Drop me a message on X or on email at support@theroadtoenterprise.com.


Want to learn something new?

Subscribe to the newsletter!


Thomas Findlay photo

About author

Thomas Findlay is a 5 star rated mentor, full-stack developer, consultant, technical writer and the author of "The Road To Enterprise" books. He works with many different technologies such as JavaScript, Vue, Nuxt, React, Next, React Native, Node.js, Python, PHP, and more. He has obtained MSc in Advanced Computer Science degree with Distinction at Exeter University, as well as First-Class BSc in Web Design & Development at Northumbria University.

Over the years, Thomas has worked with many developers and teams from beginners to advanced and helped them build and scale their applications and products. He also mentored a lot of developers and students, and helped them progress in their careers.

To get to know more about Thomas you can check out his Codementor and Twitter profiles.