[LS] TanStack Query Concepts

Before jumping into the actual content, this post (and the upcoming ones) is one of the cheat sheets I keep in my Notion account. Since these notes were intended for personal use, the writing style is such that it is easy for me to understand. I want to share them online in case someone finds them useful.


Let's say you wanted a guide of what TanStack Query (aka React Query) is and when/how to use it.


Disclaimer #1: These notes were made following the official TanStack Query (React Query) course.

Disclaimer #2: These notes are relevant to the v4 version. The v5 version has breaking changes. You can still benefit from these notes; the generic knowledge and concepts remain the same.

Why TanStack Query

TanStack Query is a library designed to make it easier to fetch and manage data from a server. With TanStack Query, you can implement data fetching, caching, and synchronization of your data with the server.

Typical fetching requirements

All these are covered by the library:

  1. Rendering the same data across multiple components without doing re-fetches.

  2. De-duplicating identical requests.

  3. Using a cache to limit the number of 'fetch' requests.

  4. Automatically refetching to have the freshest data.

  5. Handling pagination.

  6. Updating our local data when we make mutations to the remote data.

  7. Orchestrating requests that depend on the result of other requests.

Client State vs Server State

When we talk about client state, we're talking about state that is only stored in the browser.

A client state is:

  1. Ephemeral - It goes away when the browser is closed.

  2. Synchronous - It's instantly available.

  3. Client-owned - It stays local to the browser that created it.

All of this means we can always be confident that our client state is always up to date.

A server state, is persistently stored on the server and sent to the browser when they visit the page. This is for storing things like user information, posts, analytics etc - basically anything that needs to be persisted between browsing sessions.

A server state is:

  1. Stored remotely - The client has no control over what is stored or how it is stored.

  2. Asynchronous - It takes a bit of time for the data to come from the server to the client.

  3. Owned by many users - Multiple users could change the data.

Because server state is stored remotely and any user can change it, any data that comes from our server could potentially become out of date, even after only a few seconds, so we should treat it with care.

The Query Client (pt. 1)

One thing that makes TanStack Query so powerful is its ability to efficiently create and manage the cache of each one of its queries. To accomplish this, the library relies heavily on two components - QueryClient and QueryClientProvider.

QueryClient is the foundation of TanStack Query. Among other things, it keeps the cache of all of the queries that have been made and tracks the state of each query.

QueryClientProvider is what makes QueryClient available anywhere in your application when you need it.

import ReactDOM from "react-dom";
import App from "./App";
import {
  QueryClient,
  QueryClientProvider
} from "@tanstack/react-query";

const queryClient = new QueryClient();
ReactDOM.render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>,
  document.getElementById("root"),
);

The useQuery Hook

This hook performs the data fetching, caches the results, and provides us with the query state. useQuery accepts two arguments, a query key and a query function. The query key is an array that keeps track of the query in the cache. Whenever any of the items in the query key change, TanStack Query treats it as a new query and will fetch new data. The query function is the function that actually fetches the data. This function works with anything that returns a promise. The object returned by useQuery includes the fetched data, the state of the query, and a bunch of methods for interacting with the query.

import { useQuery } from "@tanstack/react-query";
function fetchUser (username) {
  return fetch(`https://api.github.com/users/${username}`)
    .then((res) => res.json())
}
export function GithubUser({ username }) {
  const { data, isLoading, isSuccess, isError, isFetching, fetchStatus } = useQuery(
    [username],
    () => fetchUser(username)
  );
  console.log(data)
  ...
  return "..."
}

Handling states

When you create a new query using useQuery, you'll get back an object that has a variety of different query states:

  • isLoading → query is being loaded for the first time. If it's true then the query has no data yet.

  • isError → query has failed.

  • isSuccess → query is successful. If it's true we are confident that the query has data. If !isLoading, !isError and data has a value then we can assume that isSuccess is true

  • isFetching → the data is being refreshed in the background.

  • fetchStatus :

    • fetching → the query function is currently running.

    • idle → the query function doesn't need to run.

    • paused → the query tried to run, but couldn't complete the request, perhaps because the network is currently offline.

QUERY KEYS AND QUERY FUNCTIONS

Query Keys

A query key is a unique value which is mapped to the data in the cache. Query keys are arrays, kind of like the dependency array of useEffect. Each item of the query key array should cause the query to refetch when it changes.

useQuery(["labels"], fetchLabels);
useQuery(["users"], fetchUsers);
useQuery(["issues"], fetchIssues);

However, we can separate the different parts of our query key into separate items, like any parameters, IDs, indices - anything that our query depends on. These could be anything: literal strings, numbers, objects, or nested arrays.

useQuery(["users", 1], fetchUser);
useQuery(["labels", labelName], fetchLabel);
useQuery(["issues", {completed: false}], fetchIssues);

Good patterns to write query keys

Take a look at this URL from the GitHub REST API:

https://api.github.com/repos/{owner}/{repo}/issues

Writing an effective query key to match this API call would work in a similar way by starting with the most generic item and going to the most specific. However, you might find it helpful to put a string at the beginning of the array key to identify the kind of data being fetched. For example, you could write:

useQuery(["issues", owner, repo], queryFn);

Just by reading the query key, we can guess that this query gives us all of the issues for a specific repo. That's what good query keys can do - they can help you know what data you are getting without figuring out what the query function actually does.

This pattern can actually help us avoid bugs, such as accidentally creating duplicate query keys.

Using Objects in Query Keys

Here's what that GitHub URL looks like when using a filter to get only closed issues:

https://api.github.com/repos/uidotdev/usehooks/issues?state=closed
function Issues({ owner, repo }) {
  const [issueState, setIssueState] = useState("open");
  const issuesQuery = useQuery(
    ["issues", owner, repo, issueState],
    queryFn
  );
  ...
}

But what if we are filtering by more than one thing?

The answer is "don't order them at all." Instead, the solution is to put all of these options in a separate object, placed somewhere in the query key. Then it doesn't matter which order they're in or whether the options are even present.

function Issues({ owner, repo }) {
  const [issueState, setIssueState] = useState("open");
  const [assignee, setAssignee] = useState();
  const [labels, setLabels] = useState("");
  const issuesQuery = useQuery(
    [
      "issues",
      owner,
      repo,
      {
        state: issueState,
        assignee,
        labels: labels || undefined
      },
    ],
    queryFn
  );
  ...
}

With that object, we can include any number of filters in the query key without needing to worry about the order. Notice also that the filter parameters are the most specific part of this query key, which is why we placed them at the end of the array.

Query Functions

A typical example is that the query function (queryFn in the examples above) uses fetch to call an HTTP API. Notice, though, that making HTTP calls isn't the only thing TanStack Query can do. In fact, any function that returns a promise is a valid query function. That means we can use third-party data fetching libraries, like Axios or graphql-request, or query any asynchronous browser API, like the geolocation API.

Let's demonstrate that by writing a query function that uses the geolocation API to get the user's location. Since that API works with callbacks instead of promises, we have to wrap it in a new Promise call.

async function getLocation() {
  return new Promise((resolve, reject) => {
    navigator
      .geolocation
      .getCurrentPosition(resolve, reject);
  });
}

function Location() {
  const locationQuery = useQuery(["location"], getLocation);
  if (locationQuery.isLoading) {
    return <p>Calculating location...</p>;
  }
  if (locationQuery.error) {
    return <p>Error: {userQuery.error.message}</p>;
  }
  return (
    <p>
      Your location is:
      {locationQuery.data.coords.latitude},
      {locationQuery.data.coords.longitude}
    </p>
  );
}

Promises

Any callback-based function can be turned into a promise using new Promise. We pass it a function with two parameters: A function to call when the promise should resolve, and another to call when the promise should reject.

The geolocation getCurrentPosition API takes a success callback and a failure callback. By passing resolve and reject to those callbacks, we can now use this function as a promise - and therefore, as a Query function with TanStack Query.

Query Function Arguments

One solution is to create an arrow function when you have access to the data you need in the same component:

const userQuery = useQuery(
  ["user", username],
 () => getGithubUser(username),
);

There is another option that might be more helpful and resilient: use the query key values. The first parameter passed to the query function is an object with a queryKey property. Since we wrote our query key as an array, we can pull out the item for the username and use that in our fetch request.

async function getGithubUser({ queryKey }) {
  const [user, username] = queryKey;
  return fetch(`https://api.github.com/users/${username}`)
    .then((res) => res.json());
};

const User = ({ username }) => {
  const userQuery = useQuery(
    ["user", username],
    getGithubUser,
  );
  if (userQuery.isLoading) {
    return <p>Getting user...</p>;
  }
  if (userQuery.error) {
    return (
      <p>Error getting user: {userQuery.error.message}</p>
    );
  }
  return <p>{userQuery.data.name}</p>;
};

Parallel queries

If you can't decide whether to use multiple useQuery hooks or put all of your requests in the same query function, think about whether you need a separate cache, loading, and error state for each query, or whether it's better for them to be combined. useQueries might be what you need.

Dependent Queries

What about when one query depends on the results of another query?

We need some way to tell TanStack Query not to run the second request until the first request is complete. This brings us to useQuery's third parameter - the configuration object.

useQuery( queryKey, queryFn, configuration)

The one that can help us with our scenario is enabled. If false, enabled will disable the query from running.

const IssueLabelFilter = ({ owner, repo }) => {
  const labelsQuery = useQuery(
    ["repos", owner, repo, "labels"],
    () => fetch(
      `https://api.github.com/repos/${owner}/${repo}/labels`
    ).then((res) => res.json())
  );
  const labels = labelsQuery.data

  const issuesQuery = useQuery(
    ["repos", owner, repo, "issues"],
    () => fetch(
      `https://api.github.com/repos/${owner}/${repo}/issues?labels=${labels[0].name}`
    ).then((res) => res.json()),
    {
      enabled: !!labels
    }
  );
  return "..."
};

Resilient Queries

Query Cache States

There are three main TanStack Query states; loading, error, and success.

For fetching the data, TanStack Query goes through the loading to success/error cycle. You can access these states on the status property of the query, or using the individual isLoading, isError, and isSuccess properties.

But what happens when data changes on the server? TanStack Query will automatically refetch our data, but how does it know when to do that? And when it refetches, will our UI revert to the loading state?

The three fetchStatus states answer the second question by telling us the state of the query request. These go through a cycle between idle, fetching, and paused. As mentioned earlier idle means the query doesn't need to be fetched, fetching means it is currently being fetched, and paused means the query tried to fetch, but was stopped for some reason - usually because the network is offline.

fetching vs isLoading

Keep in mind that the fetching state is different from the loading state. A query only has the loading state the first time it loads, and there's no data, while the fetching state is used by the query cache any time a query is refetched, including the first time.

That still doesn't explain how TanStack Query knows to refetch a query in the first place. For that, React Query uses another set of states: fresh and stale.

Once the data has been fetched, the query itself is marked as success or error, but the cache entry could be set to either fresh, meaning the data on the server is unlikely to change soon, or stale, meaning the data on the client might already be out of date with the server. By default, every query is marked as stale, and you can access the stale state of the query by reading the boolean value of isStale on the query object.

Finally, when every component that uses a query has unmounted, the cache entry for that query is marked as inactive. The data is kept just in case the query is used again, but TanStack Query might remove that entry from the cache if it isn't used for a while.

Before the query switches to inactive, though, it might switch between the fresh and stale states and idle and fetching states several times as TanStack Query triggers a refetch to keep the cache up to date.

Stale vs Fresh Data

Remember, the difference between stale and fresh has to do with when TanStack Query will refetch a query in the background. If the query is stale, it'll get refetched. If it's fresh, it won't. Knowing this gives you more control over when and how often your app refetches a query.

So how do you mark a query as fresh, and how does that query become stale? TanStack Query makes that transition based on a timer, which you can configure for each query using the staleTime configuration option on the useQuery hook. This lets you choose the number of milliseconds that a query is considered fresh after it was last fetched.

If the staleTime option is set to anything other than 0, that query will be marked as fresh when it is fetched. Then, after the staleTime duration has passed, the query will transition to stale so it can be refetched again.

Here's an example that configures React Query to keep the data fresh for one minute after it was last fetched:

const userQuery = useQuery(
  ["user", username],
  () => fetch(`https://api.github.com/users/${username}`)
            .then(res => res.json()),
  { staleTime: 1000 * 60 }
);

Once 60 seconds have passed since this query was last fetched, TanStack Query will change its state to stale, which means it's eligible to be refetched once again.

And, since you can use any number as a query's staleTime, you could make the query fresh forever by setting the staleTime to Infinity.

const userQuery = useQuery(
  ["user", username],
  () => fetch(`https://api.github.com/users/${username}`)
            .then(res => res.json()),
  { staleTime: Infinity }
);

Refetch Triggers

A query will refetch when:

  1. A component mounts.

  2. There is a window focus (e.g. clicking on the tab where the app runs).

  3. There is a network reconnection.

  4. You set an optional Interval.

Clearing the cache

Depending on how long your user is on your app and how many queries your app makes, it's possible that your cache could become full of query data that is no longer relevant. TanStack Query has a built-in mechanism to clear the cache in this scenario.

Of course, a query will only be removed from the cache when it's inactive, or when there are no mounted components using that query, so there's no risk of data suddenly disappearing from the UI. By default, when a query has been inactive for more than 5 minutes, TanStack Query clears it out.

If you want to cache a query for longer or shorter than that, you can set the cacheTime option to a number of milliseconds the inactive query data should be kept.

For example, we could remove a query from the cache immediately by setting the cacheTime option to 0:

const userQuery = useQuery(
    ["user", username],
    () => fetch(`https://api.github.com/users/${username}`)
            .then(res => res.json()),
    { cacheTime: 0 }
);

This would make it so a query is removed from the cache immediately when it becomes inactive.

What happens when a query is removed from the cache? That query will behave as if the page had been reloaded completely. The user will see the loading state until the query resolves, and then they'll see the query data.

Handling Errors

There are hundreds of reasons a query could fail in TanStack Query. We might have a network error, or the query parameters could be invalid. The server might be having a bad day, or the user might not have permission to access the data.

As far as TanStack Query is concerned, any promise rejection is an error. This could mean throwing an error in an async function, calling then reject method for manually-constructed promises, or returning the results of Promise.reject().

This is actually the default behaviour of TanStack Query. When a query fails to refetch, it will try three times to fetch it again, with a delay between each attempt. If the query still fails, it will display the error to the user. This only applies to refetches. If a query fails to fetch when it first loads, it will immediately display the error to the user without retrying.

The number of retries and the delay between each retry can be configured on the query too. By passing a number to the retry option, we can set how many times TanStack Query will retry a failing query.

const issuesQuery = useQuery(
    "issues",
    fetchIssues,
    { retry: 5, retryDelay: 5000 }
);

By default, TanStack Query will use an exponential backoff algorithm to determine how long to wait between retries. This means each attempt is exponentially longer than the previous attempt, starting with a delay of 1 second and maxing out at 30 seconds. This works well to not overwhelm the server with requests as we try to recover from errors. If you want to configure this, you can use the retryDelay option. With TanStack Query, it's most common to handle errors by checking the isError property.

We can enable error boundaries in a useQueryhook by enabling the useErrorBoundary option. This will make TanStack Query throw any errors it encounters, which makes the error boundary handle the errors instead. The error boundary will get the error object and can do whatever it needs to with it, whether that be displaying an error message, logging it, or sending it to a server.

The onError Callback

Responding to errors with imperative actions has all kinds of purposes. You might want to show a toast message or notification instead of putting the error on the page. Or you might want to log the error to a server.

We can use the onError callback that's built into TanStack Query. We assign the callback in the query configuration, and TanStack Query will call with the error object if the query fails.

const Users = () => {
    const userQuery = useQuery(
        "users",
        () => fetchWithError("/users"),
        { onError: (error) => toast.error(error.message) }
    )
    // ...
}

The Query Client (pt. 2)

The Query Client sits behind the scenes, running our queries and caching our data.

Global React Query Defaults

You can create a global configuration when you create the QueryClient, by passing it an object with a defaultOptions property. From there, you can set the default options for queries by putting the same options from useQuery inside a queries property. One example may look like this:

const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            staleTime: 3 * 1000,
            cacheTime: 10 * 1000,
            retry: 1,
        },
    },
})

Any of the other options for queries can also be assigned here, like useErrorBoundary, onError, or refetchOnWindowFocus. Whatever settings you set here will be used as the defaults for your queries across your app. And, of course, you can still override them for each useQuery, if needed.

Default Query Function

You might remember that query functions receive the query key as their parameter. This makes it possible to separate the query function from the component it is being used in while still using variable state in the function itself.

TanStack Query takes advantage of this property by letting you create a default query function for the entire app. That means every query can use this default function by only using a query key in useQuery without including a function.

Remember, it can be helpful to write your query keys in a way that mirrors the REST API that you are querying. For example, we can write the query key for the Github issues API, so it looks similar to the URL itself.

const ISSUES_URL = https://api.github.com/repos/${org}/${repo}/issues
const queryKey = ["repos", org, repo, "issues"]

Writing the query key in this way makes it possible to re-create the URL using the query key. We can do that in a query function by joining the query key into a string separated by /.

function queryGithub({queryKey}) {
    const BASE_URL = https://api.github.com/
    const apiPath = queryKey.join('/')
    const requestUrl = `${BASE_URL}${apiPath}`
    return fetch(requestUrl).then(res => res.json())
}

By writing our query keys so it matches the REST API URL, we can assemble a query for almost any endpoint just with the query key.

const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            queryFn: queryGithub
        },
    },
})

This makes it possible to write useQuery just using the query key.

const issuesQuery = useQuery(["repo", org, repo, "issues"])

This technique might be helpful for some apps, but don't feel obligated to use it. If it's particularly difficult to write query keys that match the way your API is called, you're better off writing query functions for each of your queries.

Using the Query Client

We can't use the Query Client without having access to it. To make this easy for us, React Query provides the useQueryClient hook that gives us the instance of the Query Client.

import { useQueryClient } from '@tanstack/react-query'
const queryClient = useQueryClient();

That's it. From here, we can use the Query Client to make queries, access and modify the cache, and more.

Imperative Query Fetching

The useQueryhook is strictly declarative. When put in a component, it will always trigger a query fetch (unless it is disabled). This is the behaviour you most often want, but there might be a time when you want to trigger a query imperatively.

Using the queryClient.fetchQuery method, we can tell TanStack Query to trigger that fetch for us. This method takes a queryKey and a queryFn and returns a promise that resolves to the query's result. Notice that since we are using the async/await API, we need to use try/catch blocks to handle errors.

const Search() {
  const queryClient = useQueryClient();
  const navigate = useNavigate();
  async function handleSearch(query) {
    try {
      const data = await queryClient.fetchQuery(
        ['search', query],
        performSearch
      );
      const topResult = data?.results[0];
      if (!topResult) throw new Error('No results found');

      navigate(topResult.url);
    } catch(error) {
      // handle error
    }
  }
  return (
    <form onSubmit={event => {
      event.preventDefault();
      handleSearch(event.target.query.value);
    }}>
      <input name="query" />
      <button type="submit">Search</button>
    </form>
  );
}

MANUAL QUERY INVALIDATION

TanStack Query will only ever refetch a query if it is stale, but by default all queries are stale once their results come back. Queries with the staleTime option will remain fresh until the staleTime has passed.

As mentioned earlier, there are three things which will cause TanStack Query to automatically refetch any stale queries: Any time a component that has the useQuery hook mounts, whenever the user focuses the browser window, or whenever the network goes offline and then comes back online.

TanStack Query will only ever refetch active queries - that is, queries that are being used by components that are currently mounted. inactive queries remain in the cache until their cacheTime expires, which is 5 minutes by default.

Refetching Queries

There are times you might know for a fact that the data has changed and should be updated.

After getting access to the query client with useQueryClient, we can tell TanStack Query to refetch a query with the queryClient.refetchQueries method. All that it needs is a query key to refetch.

const RefetchButton = ({org, repo}) => {
  const queryClient = useQueryClient();
  return (
    <button onClick={() =>
      queryClient.refetchQueries(['repo', org, repo])
      }>
      Refresh
    </button>
  );
};

When the user clicks this button, TanStack Query will have every query that matches the provided query key refetch in the background.

One thing that might not be obvious, though, is that it will cause every query to refetch - including inactive queries. Sometimes this is exactly what you want, but most of the time it should be avoided, since it can cause a lot of unnecessary refetches.

Fortunately, there is an alternative method of triggering refetches which is likely more useful for what you want to do.

Invalidating Queries

Most of the time, we just want to mark queries as stale and let TanStack Query handle the refetching itself. That's where queryClient.invalidateQueries comes in.

When you call queryClient.invalidateQueries with a query key, it will mark all queries that match that key as stale. In addition, any queries that are active, meaning they are currently being used in components that are mounted, will be refetched.

const InvalidateButton = ({org, repo}) => {
  const queryClient = useQueryClient();
  return (
    <button onClick={() =>
      queryClient.invalidateQueries(['repo', org, repo])
    }>
      Invalidate Queries
    </button>
  );
};

Clicking that button will cause all queries that match the provided query key to be marked as stale , and all active queries will be refetched.

Refetching vs Invalidating

queryClient.refetchQueries will force any queries that match the provided query key to refetch. This includes active, inactive, fresh, and stale queries.

queryClient.invalidateQueries, on the other hand, will only mark any fresh queries as stale , automatically triggering a refetch. However, since TanStack Query will never automatically refetch inactive queries, queryClient.invalidateQueries results in fewer queries refetching, which means less network traffic.

This is a very slight difference, but it can have a huge impact when you have dozens of queries in your app.

A good rule to follow: If you know that the query absolutely needs to refetch, even if its inactive, use queryClient.refetchQueries. For every other situation, use the queryClient.invalidateQueries.

QUERY FILTERS

We've already used one type of query filter: the query key itself. Normally we use it to match a specific query, but it also can match several queries at once.

Let's look at an example to illustrate this. Here are queries we'll be working with. These are scattered throughout many components in our app.

const repoQuery = useQuery(['repo', org, repo]);
const repoIssuesQuery = useQuery(['repo', org, repo, 'issues']);
const openIssuesQuery = useQuery(['repo', org, repo, 'issues', {state: 'open'}]);

If we wanted to refetch the first query, we would need to pass its query key to queryClient.refetchQueries .

queryClient.refetchQueries(['repo', org, repo]);

If we were to actually run this code, we might see something surprising happen - all three of the queries would refetch. While this may seem like a bug, it's actually a powerful feature. TanStack Query doesn't match the exact query key. Instead, it tries to match with any query that has a key similar to the first part of the query key provided.

In fact, queryClient.refetchQueries can be called without any query keys, which will make it refetch every query in the app.

queryClient.refetchQueries();

if I only want to refetch the second query?" For that, we can pass in a second parameter with {exact:true} which tells TanStack Query to only refetch the exact query key.

queryClient.refetchQueries(
  ['repo', org, repo, "issues"],
  {exact: true}
);

Query keys are only one way to filter queries. You can also filter queries based on their cache state.

We can combine query keys with these filter objects. We can do that by passing the query key as the first parameter and the filter object as the second. This next example will refetch all of the issues queries that are stale , including the inactive ones.

queryClient.refetchQueries(
  ['repo', org, repo, "issues"],
  {stale: true, type: "all"}
);

QUERY CANCELLATION

Most of the time TanStack Query will cancel the query automatically. For example, the component might unmount before the query resolves, or the query key might change mid-request, making the data unusable.

TanStack Query tells us that a query has been cancelled using the AbortController API. TanStack Query handles creating the AbortController and passing its signal to the query function.

Ultimately, it's up to the query function to do what needs to be done to cancel the query. Here's an example where the query returns a string after a timeout. We'll know that the query was cancelled by listening for the abort event on the signal that was passed in. Then, we'll just call clearTimeout on the timeout.

const DelayComponent = () => {
  const delayQuery = useQuery(
    ["delay"],
    ({signal}) => {
      let timeout;
      const delayPromise = new Promise(resolve => {
        timeout = setTimeout(() => resolve("Hello"), 1000);
      })
      signal?.addEventListener("abort", () => {
        clearTimeout(timeout);
      });
      return delayPromise;
    })
  // ...
}

Prefetching Queries

Placeholder vs Initial Data

Placeholder Data and Initial Data are very similar on the surface, but there are big differences between them.

The biggest difference between these two is what happens in the cache. Initial Data puts the data right in the cache while Placeholder Data does not. But why does that even matter? Won't the cached data be overwritten once the data from the server is loaded?

Let's suppose we pass some fake data to initialData. This data is stored in the cache and is returned by the useQuery hook as data. The end result is the same as placeholderData, right? Not exactly.

You want to be careful about what you put into the cache, especially when you're working with fake data. Since any component can access any data in the cache from anywhere in your app using queryClient.getQueryData, another component could grab that fake data from the cache while the real data is loading. Now, somewhere else in your app has to deal with that fake data.

This also affects the way errors are handled. With initialData, if the query function throws an error, it will be shown alongside the fake data that was passed in. With placeholderData, the query data will be removed as soon as the query function throws an error, the placeholder data is removed, and only the error appears.

To be safe, you should probably use only real data with Initial Data and only use dummy data with Placeholder Data. That will make sure garbage data doesn't make it into the cache while still providing a nicer loading experience for your users.

Initial Data usage

By passing a function to initialData instead of the data itself, we can avoid executing expensive code every time our component renders, but TanStack Query knows to run the function when it actually needs to get initial data. We can also use queryClient.getQueryData. We can pass it a query key, and if there is data in the cache it will return it to us. Otherwise, we get undefined. With that query data, we can search to find the individual issue we are looking for and return that from our initialData function.

const queryClient = useQueryClient();
const issueDetailQuery = useQuery(
  ["issue", repo, owner, issueNumber],
  fetchIssue,
  {
    initialData: () => {
      const issues = queryClient.getQueryData(["issues", repo, owner])
      if (!issues) return undefined;
      const issue = issues.find(issue => issue.number === issueNumber)
      return issue;
    }
  },
)

P.S You can also use a function for placeholderData if you don't want the data to be cached.

Preemptive Data

If we know that a query can provide the initial data for another query, we can preemptively put the needed data into the cache when the first query loads. Then, when the second query loads, it will automatically have data from the cache, without any extra configuration on our part. We can push data into the cache by using the queryClient.setQueryData method, which lets us update a cache entry for a particular query key. The best place to call this is inside our query function, after the data has been loaded.

Mutations

useMutation vs useQuery

The first and most obvious difference is the mutatemethod, which is what you call to fire off the mutation. You see, useQuery runs declaratively. As soon as the component mounts, it fetches the data. Instead, useMutation is imperative, only firing off the request when you call the mutation.mutate method. We can still access the state of the useMutation call declaratively and use it in our render function.

The second difference between useMutation and useQuery is in the parameters - you don't have to pass a query mutation key, just the mutation function. This implies another difference - mutation results aren't cached like query results are. If you make a mutation in one component, you won't be able to access the response data in another component without sending it some other way.

In fact, useMutation does nothing by default to update the query cache.

TanStack Query makes no assumptions about what your data returns, which means you are responsible for making sure queries are updated when the mutation response comes back. Fortunately, TanStack Query gives us the tools to handle this.

async function changeName(newName) {
  const response = await fetch('https://api.github.com/user', {
    method: 'PATCH',
    headers: {
      authorization: `token ${window.GITHUB_TOKEN}`,
      'content-type': 'application/json',
    },
    body: JSON.stringify({
      name: newName,
    }),
  })

  const result = await response.json()
  if (!response.ok) {
    throw new Error(result.message)
  }
  return result
}

const Username = () => {
  const userQuery = useQuery(['user'], fetchUser)
  const changeNameMutation = useMutation(changeName)

  if (userQuery.isLoading) return <p>Loading...</p>
  return (
    <>
      <button
        onClick={() => {
          const newName = prompt("What's your new name?")
          changeNameMutation.mutate(newName)
        }}
      >
        {changeNameMutation.isLoading ? 'Updating - ' : ''}
        {userQuery.data.name}
      </button>
      {changeNameMutation.isError && <p>{changeNameMutation.error}</p>}
      {changeNameMutation.isSuccess && <p>Name Updated. Hello {changeNameMutation.data.name}.</p>}
    </>
  )
}

Optimistic updates

Optimistic UI is a feature of onMutate.

Rollbacks

Rollbacks work by taking advantage of a special feature that onMutate has: Whatever you return from onMutate is available to the onSuccess, onError, and onSettled callbacks as the third argument.

It's usually best to return a rollback function which can be called in onSuccess or onError. This lets us make a snapshot of the cache before the mutation runs, and then call the function to restore the cache to its state before we added the fake comment.

Remember, the rollback function can do whatever we want it to. If you prefer, you could surgically remove the specific comment from the cache using its ID. This could be helpful if your user fires off many mutations rapidly to keep the cache from getting in an incorrect state.

const addCommentMutation = useMutation(addComment, {
  onMutate: (variables) => {
    const comment = {
      id: Math.random().toString(),
      body: variables.comment,
      created_at: new Date(),
      user: {
        login: username,
      },
    }
    queryClient.setQueryData(['comments', org, repo, issueNumber], (comments) => comments.concat(comment))

    return () => {
      queryClient.setQueryData(['comments', org, repo, issueNumber], (currentCache) =>
        currentCache.filter((cacheComment) => cacheComment.id !== comment.id),
      )
    }
  },
  // ...
})

How you craft your rollback function is up to you. Its purpose is to make it easier for you to reverse the changes you made during the optimistic update. If you can figure out how to craft the fake data and a rollback function, optimistic updates can give your users a fantastic, speedy experience. And you can always make sure your queries are up to date eventually by invalidating the query in onSettled.

Pagination

keepPreviousData

By passing keepPreviousData: true as a configuration option on the query, whenever the query key changes, the query will continue providing the last query's data until the data for the new query key is available. Then, TanStack Query will seamlessly transition to the new data.

We can use this option on any query, such as one that filters or sorts data, to keep the previous results while the next results load. But it's especially useful for pagination. In fact, we can solve the loading state problem by simply adding keepPreviousData: true to the query config.

const Issues = ({org, repo}) => {
  // ...
  const issuesQuery = useQuery(
    ["issues", org, repo, {page, perPage}],
    fetchIssues,
    {keepPreviousData: true}
  );

  return (
    // ...
  )
}