Concurrent Optimistic Updates in React Query
โ ReactJs, React Query, Mutations, TypeScript, JavaScript โ 5 min read

- #1: Practical React Query
- #2: React Query Data Transformations
- #3: React Query Render Optimizations
- #4: Status Checks in React Query
- #5: Testing React Query
- #6: React Query and TypeScript
- #7: Using WebSockets with React Query
- #8: Effective React Query Keys
- #8a: Leveraging the Query Function Context
- #9: Placeholder and Initial Data in React Query
- #10: React Query as a State Manager
- #11: React Query Error Handling
- #12: Mastering Mutations in React Query
- #13: Offline React Query
- #14: React Query and Forms
- #15: React Query FAQs
- #16: React Query meets React Router
- #17: Seeding the Query Cache
- #18: Inside React Query
- #19: Type-safe React Query
- #20: You Might Not Need React Query
- #21: Thinking in React Query
- #22: React Query and React Context
- #23: Why You Want React Query
- #24: The Query Options API
- #25: Automatic Query Invalidation after Mutations
- #26: How Infinite Queries work
- #27: React Query API Design - Lessons Learned
- #28: React Query - The Bad Parts
- #29: Concurrent Optimistic Updates in React Query
- No translations available.
- Add translation
Optimistic Updates are one of those techniques that are great to make your app feel faster than it really is, but it's also one of the things that's easiest in todo-app style demos.
Look, I can instantly append a task to a list when I press Enter
on the input field. That's great in theory, but in practice, there's likely more challenges awaiting you.
Re-creating server logic on the client
I have already written a bit about this in #12: Mastering Mutation in React Query, but it's an important point to re-iterate on. With optimistic UI, you're essentially trying to foresee what the server will do, and implement that on the client beforehand.
This can be quite straight-forward, for example, if the user is interacting with a toggle button. Usually, all we need to do is take the current boolean state and invert it:
1queryClient.setQueryData(['items', 'detail', item.id], (prevItem) =>2 prevItem3 ? {4 ...prevItem,5 isActive: !prevItem.isActive,6 }7 : undefined8)
That's not too much code to write, and it has a great effect on the UX: Rather than having to wait until the request is finished before the UI reacts, our users can now click the button and it instantly see their change reflected. There's not much that's worse than a toggle button that slides to the new state half a second after I clicked it. ๐
More complex update logic
In other situations, it's not that easy, and we don't have to invent a complex scenario to get there. Let's just assume we have a list of items that have a category, which our users can use for filtering. When they edit an item, we show that in a modal dialog, and once they're done, we want to optimistically update the list they are currently seeing.
Finding the item in the list of items is not the problem, and merging the updates is also not that bad:
1queryClient.setQueryData(['items', 'list', filters], (prevItems) =>2 prevItems?.map((item) =>3 item.id === newItem.id ? { ...item, ...newItem } : item4 )5)
This, too, works great, until we realize the edge-case when a user updates a category of our item, which would remove the item from the current filter. We aren't handling that yet. Even the GitHub list view gets that wrong when we're using inline editing to remove a label
that we have currently filtered for. Let's fix that:
1queryClient.setQueryData(['items', 'list', filters], (prevItems) =>2 prevItems3 ?.map((item) =>4 item.id === newItem.id ? { ...item, ...newItem } : item5 )6 .filter((item) => filters.categories.includes(item.category))7)
Now, the item optimistically disappears, which is what we'd want, because that's what the server would do when the list gets refetched. We just forgot that we can also filter by text. Now we need to do the same thing for item.title
and ...
I hope you get the point by now that in many realistic scenarios, optimistic updates come with the drawback of having to know exactly what will happen on the server, and having to re-create and duplicate that logic on the client. Sometimes, that's totally worth it, but I'd also say that a lot of times, it might not be. And it gets even harder if our users can update the same entity multiple times at the same time.
Concurrent Optimistic Updates
Let's go back to our toggle button example once more, but this time, let's code the full optimistic mutation:
1const useToggleIsActive = (id: number) =>2 useMutation({3 mutationFn: api.toggleIsActive,4 onMutate: async () => {5 await queryClient.cancelQueries({6 queryKey: ['items', 'detail', id],7 })8
9 queryClient.setQueryData(['items', 'detail', id], (prevItem) =>10 prevItem11 ? {12 ...prevItem,13 isActive: !prevItem.isActive,14 }15 : undefined16 )17 },18 onSettled: () => {19 queryClient.invalidateQueries({20 queryKey: ['items', 'detail', id],21 })22 },23 })
This is a minimal example where we write to the cache before the mutation starts, and invalidate it once it finished. I've omitted the rollback
logic you'll usually see and that we also show in the docs because it's irrelevant for what I want to talk about, and you can also get by without it. ๐
Window of Inconsistency
One thing I didn't leave out is the manual Query Cancellation, because it's pretty relevant to avoid the window of inconsistency. Without it, here is what could happen:

If our mutation starts while we already have a request in-flight, our optimistic update would get overwritten when that request finishes. Since we start another invalidation at the end, it would be fine eventually, but it can create a jarring user experience where state toggles back and forth. This behaviour can most commonly happen when users focus a screen to perform an update, and that focus event will trigger an invalidation thanks to refetchOnWindowFocus
.
Query Cancellation
Query cancellation fixes this because it essentially aborts all currently running queries that could interfere with our optimistic update when the mutation starts:

It even works fine if we have multiple mutations that write to the same entity, given that the invalidation is already running when the second mutation starts. But that's not a given. Let's look at this scenario where query cancellation doesn't help:

Here, the second mutation starts while the first mutation is still running, so when it begins, there is nothing it could cancel. But then, the first mutation settles and we call queryClient.invalidateQueries
. If that refetch is faster than our second mutation, our UI will revert and we'll see the dreaded window of inconsistency again.
Note that this is quite an edge case. If the second mutation wouldn't take that long, the second invalidation would also cancel the first invalidation, as imperative calls to invalidateQueries
cancel refetches per default. You might see your concurrent mutations working fine for weeks, and then you'll get this flash-of-old-UI once and don't know where it comes from. So how can we fix this?
Preventing over-invalidation
The problem is actually right there in our code: every time when a mutation settles, we invalidate:
1onSettled: () => {2 queryClient.invalidateQueries({3 queryKey: ['items', 'detail', id],4 })5}
So what if we made that a little bit smarter? Looking at the diagram from above, we know that the first invalidation is fruitless because there's a "related" mutation ongoing, which will also do an invalidation at the end. The trick is to skip those invalidations, and it's just one line of code:
1onSettled: () => {2 if (queryClient.isMutating() === 1) {3 queryClient.invalidateQueries({4 queryKey: ['items', 'detail', id],5 })6 }7}
queryClient.isMutating()
is an imperative way to look at how many mutations are currently running. And since we only want to do an invalidation if there's no other mutation in-flight, we check for 1
. That's because when onSettled
is invoked, our own mutation is still in progress, so this count will never be 0
here. Note that we really want this check to happen imperatively, right before calling the invalidation. If we'd switch this to useIsMutating()
, we'd likely run into stale closure issues.
Limiting the scope
The check is currently pretty wide. If there's any other mutation running, our invalidation will be skipped. That's fine if we have no other mutations in progress, or if we invalidate everything at the end anyways.
But if we do fine-grained invalidations, we have to be careful about not skipping too many of them. A good balance would be to tag related mutations with a mutationKey
, then use that as a filter for isMutating
:
1const useToggleIsActive = (id: number) =>2 useMutation({3 mutationKey: ['items'],4 mutationFn: api.toggleIsActive,5 onMutate: async () => {6 await queryClient.cancelQueries({7 queryKey: ['items', 'detail', id],8 })9
10 queryClient.setQueryData(['items', 'detail', id], (prevItem) =>11 prevItem12 ? {13 ...prevItem,14 isActive: !prevItem.isActive,15 }16 : undefined17 )18 },19 onSettled: () => {20 if (queryClient.isMutating({ mutationKey: ['items'] }) === 1) {21 queryClient.invalidateQueries({22 queryKey: ['items', 'detail', id],23 })24 }25 },26 })
With this code, this is how our final flow looks like:

It's pretty resilient. Thanks' to query cancellation and limited invalidations, we should never see a flickering UI that shows an inconsistent state. ๐
That's it for today. Feel free to reach out to me on bluesky if you have any questions, or just leave a comment below. โฌ๏ธ
