Mastering Mutations in React Query
— ReactJs, React Query, JavaScript, TypeScript — 7 min read
Last Update: 2023-10-21
- #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
We've covered a lot of ground already when it comes to the features and concepts React Query provides. Most of them are about retrieving data - via the useQuery
hook. There is however a second, integral part to working with data: updating it.
For this use-case, React Query offers the useMutation
hook.
What are mutations?
Generally speaking, mutations are functions that have a side effect. As an example, have a look at the push
method of Arrays: It has the side effect of changing the array in place where you're pushing a value to:
1const myArray = [1]2myArray.push(2)3
4console.log(myArray) // [1, 2]
The immutable counterpart would be concat
, which can also add values to an array, but it will return a new Array instead of directly manipulating the Array you operate on:
1const myArray = [1]2const newArray = myArray.concat(2)3
4console.log(myArray) // [1]5console.log(newArray) // [1, 2]
As the name indicates, useMutation also has some sort of side effect. Since we are in the context of managing server state with React Query, mutations describe a function that performs such a side effect on the server. Creating a todo in your database would be a mutation. Logging in a user is also a classic mutation, because it performs the side effect of creating a token for the user.
In some aspects, useMutation
is very similar to useQuery
. In others, it is quite different.
Similarities to useQuery
useMutation
will track the state of a mutation, just like useQuery
does for queries. It'll give you loading
, error
and status
fields to make it easy for you to display what's going on to your users.
You'll also get the same nice callbacks that useQuery
has: onSuccess
, onError
and onSettled
. But that's about where the similarities end.
Differences to useQuery
By that, I mean that queries mostly run automatically. You define the dependencies, but React Query takes care of running the query immediately, and then also performs smart background updates when deemed necessary. That works great for queries because we want to keep what we see on the screen in sync with the actual data on the backend.
For mutations, that wouldn't work well. Imagine a new todo would be created every time you focus your browser window 🤨. So instead of running the mutation instantly, React Query gives you a function that you can invoke whenever you want to make the mutation:
1function AddComment({ id }) {2 // this doesn't really do anything yet3 const addComment = useMutation({4 mutationFn: (newComment) =>5 axios.post(`/posts/${id}/comments`, newComment),6 })7
8 return (9 <form10 onSubmit={(event) => {11 event.preventDefault()12 // ✅ mutation is invoked when the form is submitted13 addComment.mutate(14 new FormData(event.currentTarget).get('comment')15 )16 }}17 >18 <textarea name="comment" />19 <button type="submit">Comment</button>20 </form>21 )22}
Another difference is that mutations don't share state like useQuery
does. You can invoke the same useQuery
call multiple times in different components and will get the same, cached result returned to you - but this won't work for mutations.
Tying mutations to queries
Mutations are, per design, not directly coupled to queries. A mutation that likes a blog post has no ties towards the query that fetches that blog post. For that to work, you would need some sort of underlying schema, which React Query doesn't have.
To have a mutation reflect the changes it made on our queries, React Query primarily offers two ways:
Invalidation
This is conceptually the simplest way to get your screen up-to-date. Remember, with server state, you're only ever displaying a snapshot of data from a given point in time. React Query tries to keep that up-to-date of course, but if you're deliberately changing server state with a mutation, this is a great point in time to tell React Query that some data you have cached is now "invalid". React Query will then go and refetch that data if it's currently in use, and your screen will update automatically for you once the fetch is completed. The only thing you have to tell the library is which queries you want to invalidate:
1const useAddComment = (id) => {2 const queryClient = useQueryClient()3
4 return useMutation({5 mutationFn: (newComment) =>6 axios.post(`/posts/${id}/comments`, newComment),7 onSuccess: () => {8 // ✅ refetch the comments list for our blog post9 queryClient.invalidateQueries({10 queryKey: ['posts', id, 'comments']11 })12 },13 })14}
Query invalidation is pretty smart. Like all Query Filters, it uses fuzzy matching on the query key. So if you have multiple keys for your comments list, they will all be invalidated. However, only the ones that are currently active will be refetched. The rest will be marked as stale, which will cause them to be refetched the next time they are used.
As an example, let's assume we have the option to sort our comments, and at the time the new comment was added, we have two queries with comments in our cache:
1['posts', 5, 'comments', { sortBy: ['date', 'asc'] }2['posts', 5, 'comments', { sortBy: ['author', 'desc'] }
Since we're only displaying one of them on the screen, invalidateQueries
will refetch that one and mark the other one as stale.
Direct updates
Sometimes, you don't want to refetch data, especially if the mutation already returns everything you need to know. If you have a mutation that updates the title of your blog post, and the backend returns the complete blog post as a response, you can update the query cache directly via setQueryData
:
1const useUpdateTitle = (id) => {2 const queryClient = useQueryClient()3
4 return useMutation({5 mutationFn: (newTitle) =>6 axios7 .patch(`/posts/${id}`, { title: newTitle })8 .then((response) => response.data),9 // 💡 response of the mutation is passed to onSuccess10 onSuccess: (newPost) => {11 // ✅ update detail view directly12 queryClient.setQueryData(['posts', id], newPost)13 },14 })15}
Putting data into the cache directly via setQueryData
will act as if this data was returned from the backend, which means that all components using that query will re-render accordingly.
I'm showing some more examples of direct updates and the combination of both approaches in #8: Effective React Query Keys.
I personally think that most of the time, invalidation should be preferred. Of course, it depends on the use-case, but for direct updates to work reliably, you need more code on the frontend, and to some extent duplicate logic from the backend. Sorted lists are for example pretty hard to update directly, as the position of my entry could've potentially changed because of the update. Invalidating the whole list is the "safer" approach.
Optimistic updates
Optimistic updates are one of the key selling points for using React Query mutations. The useQuery cache gives us data instantly when switching between queries, especially when combined with prefetching. Our whole UI feels very snappy because of it, so why not get the same advantage for mutations as well?
A lot of the time, we are quite certain that an update will go through. Why should the user wait for a couple of seconds until we get the okay from the backend to show the result in the UI? The idea of optimistic updates is to fake the success of a mutation even before we have sent it to the server. Once we get a successful response back, all we have to do is invalidate our view again to see the real data. In case the request fails, we're going to roll back our UI to the state from before the mutation.
This works great for small mutations where instant user feedback is actually required. There is nothing worse than having a toggle button that performs a request, and it doesn't react at all until the request has completed. Users will double or even triple click that button, and it will just feel "laggy" all over the place.
Example
I've decided to not show an additional example. The official docs cover that topic very well, and they also have a codesandbox example in TypeScript.
I further think that optimistic updates are a bit over-used. Not every mutation needs to be done optimistically. You should really be sure that it rarely fails, because the UX for a rollback is not great. Imagine a Form in a Dialog that closes when you submit it, or a redirect from a detail view to a list view after an update. If those are done prematurely, they are hard to undo.
Also, be sure that the instant feedback is really required (like in the toggle button example above). The code needed to make optimistic updates work is non-trivial, especially compared to "standard" mutations. You need to mimic what the backend is doing when you're faking the result, which can be as easy as flipping a Boolean or adding an item to an Array, but it might also get more complex really fast:
- If the todo you're adding needs an id, where do you get it from?
- If the list you're currently viewing is sorted, will you insert the new entry at the right position?
- What if another user has added something else in the meantime - will our optimistically added entry switch positions after a refetch?
All these edge cases might make the UX actually worse in some situations, where it might be enough to disable the button and show a loading animation while the mutation is in-flight. As always, choose the right tool for the right job.
Common Gotchas
Finally, let's dive into some things that are good to know when dealing with mutations that might not be that obvious initially:
awaited Promises
Promises returned from the mutation callbacks are awaited by React Query, and as it so happens, invalidateQueries
returns a Promise. If you want your mutation to stay in loading
state while your related queries update, you have to return the result of invalidateQueries
from the callback:
1{2 // 🎉 will wait for query invalidation to finish3 onSuccess: () => {4 return queryClient.invalidateQueries({5 queryKey: ['posts', id, 'comments'],6 })7 }8}9{10 // 🚀 fire and forget - will not wait11 onSuccess: () => {12 queryClient.invalidateQueries({13 queryKey: ['posts', id, 'comments']14 })15 }16}
Mutate or MutateAsync
useMutation
gives you two functions - mutate
and mutateAsync
. What's the difference, and when should you use which one?
mutate
doesn't return anything, while mutateAsync
returns a Promise containing the result of the mutation. So you might be tempted to use mutateAsync
when you need access to the mutation response, but I would still argue that you should almost always use mutate
.
You can still get access to the data
or the error
via the callbacks, and you don't have to worry about error handling: Since mutateAsync
gives you control over the Promise, you also have to catch errors manually, or you might get an unhandled promise rejection.
1const onSubmit = () => {2 // ✅ accessing the response via onSuccess3 myMutation.mutate(someData, {4 onSuccess: (data) => history.push(data.url),5 })6}7
8const onSubmit = async () => {9 // 🚨 works, but is missing error handling10 const data = await myMutation.mutateAsync(someData)11 history.push(data.url)12}13
14const onSubmit = async () => {15 // 😕 this is okay, but look at the verbosity16 try {17 const data = await myMutation.mutateAsync(someData)18 history.push(data.url)19 } catch (error) {20 // do nothing21 }22}
Handling errors is not necessary with mutate
, because React Query catches (and discards) the error for you internally. It is literally implemented with: mutateAsync().catch(noop)😎
The only situations where I've found mutateAsync
to be superior is when you really need the Promise for the sake of having a Promise. This can be necessary if you want to fire off multiple mutations concurrently and want to wait for them all to be finished, or if you have dependent mutations where you'd get into callback hell with the callbacks.
Mutations only take one argument for variables
Since the last argument to mutate
is the options object, useMutation
can currently only take one argument for variables. This is certainly a limitation, but it can be easily worked around by using an object:
1// 🚨 this is invalid syntax and will NOT work2const mutation = useMutation({3 mutationFn: (title, body) => updateTodo(title, body),4})5mutation.mutate('hello', 'world')6
7// ✅ use an object for multiple variables8const mutation = useMutation({9 mutationFn: ({ title, body }) => updateTodo(title, body),10})11mutation.mutate({ title: 'hello', body: 'world' })
To read more on why that is currently necessary, have a look at this discussion.
Some callbacks might not fire
You can have callbacks on useMutation
as well as on mutate
itself. It is important to know that the callbacks on useMutation
fire before the callbacks on mutate
. Further, the callbacks on mutate
might not fire at all if the component unmounts before the mutation has finished.
That's why I think it's a good practice to separate concerns in your callbacks:
- Do things that are absolutely necessary and logic related (like query invalidation) in the
useMutation
callbacks. - Do UI related things like redirects or showing toast notifications in
mutate
callbacks. If the user navigated away from the current screen before the mutation finished, those will purposefully not fire.
This separation is especially neat if useMutation
comes from a custom hook, as this will keep query related logic in the custom hook while UI related actions are still in the UI. This also makes the custom hook more reusable, because how you interact with the UI might vary on a case by case basis - but the invalidation logic will likely always be the same:
1const useUpdateTodo = () =>2 useMutation({3 mutationFn: updateTodo,4 // ✅ always invalidate the todo list5 onSuccess: () => {6 queryClient.invalidateQueries({7 queryKey: ['todos', 'list']8 })9 },10 })11
12// in the component13
14const updateTodo = useUpdateTodo()15updateTodo.mutate(16 { title: 'newTitle' },17 // ✅ only redirect if we're still on the detail page18 // when the mutation finishes19 { onSuccess: () => history.push('/todos') }20)
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. ⬇️