While using TanStack Query, I started wondering whether there was a smoother way to invalidate queries after a mutation, and that's how I ended up introducing automatic query invalidation.
I'd like to share how I went about it and what I learned.
When we build web applications, there are so many times we need to fetch data (resources) from a server and show it on the screen.
For example, fetching and displaying a list of users or a user's detail information.
A query is what we use to fetch data from the server like this, and it's a declarative dependency on asynchronous data tied to a unique key.
In TanStack Query, you declare a query with the useQuery hook, and you can fetch or invalidate queries through the QueryClient.
A mutation is an asynchronous operation with a different purpose from a query.
Unlike queries, whose main job is reading, mutations change data.
For example, updating or deleting user information.
In TanStack Query, you declare and run a mutation with the useMutation hook.
Mutations mainly handle changing (Update) data.
Here's a table summarizing the two:
Category
Query
Mutation
Main purpose
Reading data (Read)
Changing data (Update)
Hook used
useQuery
useMutation
Main features
Fetching data from the server, data caching
Creating/updating/deleting data
How it runs
Runs automatically through declarative dependencies
Runs through an explicit function call
Management tool
Managed through QueryClient
Run directly through the useMutation hook
Examples
Fetching a user list, fetching detail information
Updating or deleting user information
When a mutation completes successfully, you can define what happens through the onSuccess callback.
You can also use the onSettled callback to define behavior regardless of whether it succeeded or failed.
If a mutation succeeds, it's likely to affect some queries.
If data changes but the user's screen doesn't reflect it, the user experience suffers and people may start to lose trust in the application.
For example, if you press the like button on a post and the like count goes up but the screen doesn't show it, users would be confused. š«Ø
It would be nice if TanStack Query just handled refetching on its own at this point, but TanStack Query is designed to let developers decide how to handle resources here.
Some developers may not like refetching, and others may want to take the changed data returned from the mutation and update the cache with it.
So how can we invalidate queries after a mutation and trigger a refetch?
As mentioned earlier, you can define what happens on mutation success through the onSuccess callback.
This lets us invalidate queries when a mutation succeeds. To do that, we use a QueryClient instance.
First, get the closest QueryClient instance in your component with the useQueryClient hook.
Then you can invalidate queries with the instance's invalidateQueries method.
The example above simply declares a mutation that updates like information and invalidates the likes query on success.
This way, a refetch runs when the mutation succeeds.
While working at my company, I often ran into situations where a single mutation needed to invalidate several queries.
There was a lot of this kind of code in the codebase, and with so much duplication, readability suffered and maintenance got harder.
const queryClient = new QueryClient({ mutationCache: new MutationCache({ onSuccess: () => queryClient.invalidateQueries(), }),});
With just 3 added lines of code, you can invalidate every query whenever a mutation succeeds.
There's no need to write separate invalidation logic in each component.
You also don't need to use the useQueryClient hook to get the QueryClient instance.
When a mutation succeeds, the active queries get refreshed automatically, which keeps your data consistent.
However, since this approach invalidates all queries, it can cause unnecessary refetches.
Invalidating every query can be implemented simply with very little code, but it felt like overkill that the user's profile information would also get invalidated just because they redeemed a voucher.
To invalidate only specific queries, I needed to decide which queries to invalidate based on information about the mutation.
The example above declares a mutation that redeems a voucher and identifies the mutation through mutationKey.
When the mutation succeeds, it invalidates the queries matching the queryKey through mutation.options.mutationKey.
If a mutation has no mutationKey, all queries get invalidated.
Since I decided that invalidating every query wasn't necessary, I looked for another approach.
meta is an option that lets developers, meaning users of the library, store additional information on queries or mutations.
With this option, you can attach tags to a mutation. These tags can then be used to decide which queries should be invalidated.
Here, you need to use a predicate function so that only mutations matching certain conditions are handled selectively.
Inside the function, you can use the matchQuery function to check whether a query matches the condition.
Inside the invalidateMatchedQueries function, it checks whether a query matches a queryKey defined in the meta data.
Then it invalidates the queries that return true.
To control mutation invalidation through the meta option, you can add code like this:
Previously, I used an imperative approach, directly calling instance methods inside callback functions. But by introducing the meta option, I was able to define query invalidation declaratively, and that cut down a lot on code duplication.
In particular, using mutationCache together with meta let me implement query invalidation in a consistent way across the whole system.
Introducing automatic invalidation with TanStack Query brought a number of positive changes.
By defining invalidation logic declaratively and managing it in one central place, readability and maintainability of the code improved, and as a result, development productivity and code quality went up too.