[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:
Rendering the same data across multiple components without doing re-fetches.
De-duplicating identical requests.
Using a cache to limit the number of 'fetch' requests.
Automatically refetching to have the freshest data.
Handling pagination.
Updating our local data when we make mutations to the remote data.
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:
Ephemeral - It goes away when the browser is closed.
Synchronous - It's instantly available.
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:
Stored remotely - The client has no control over what is stored or how it is stored.
Asynchronous - It takes a bit of time for the data to come from the server to the client.
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'strue
then the query has no data yet.isError
→ query has failed.isSuccess
→ query is successful. If it'strue
we are confident that the query has data. If!isLoading
,!isError
anddata
has a value then we can assume thatisSuccess
istrue
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:
A component mounts.
There is a window focus (e.g. clicking on the tab where the app runs).
There is a network reconnection.
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 useQuery
hook 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 useQuery
hook 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 mutate
method, 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 (
// ...
)
}