Undoable Mutations
I want to be able to undo a mutation for a few seconds after
mutate() runs. Here's a summary of what I want to happen after running mutate():
1. Do an Optimistic Update.
2. Run onSuccess and onSettled callbacks.
3. Show a notification with an undo button for 3 seconds. If the undo button is clicked, reverse the Optimistic Update and cancel the mutation.
4. After 3 seconds, if the undo button wasn't clicked, run the mutationFn.
I've tried a few approaches, but it always feels hacky.
How would you implement such a feature? Is there any example that can inspire me?21 Replies
genetic-orange•2y ago
1. Create a submit function
2. When user click submit run this function
3. First you should do is to show notification with an undo button.
4. You create
setTimeout with delay for 3 secs and assign it's id to clearTimeout fn
5. If users click undo you call clearTimeout with that id.
6. If user doesn't click undo setTimeout calls your mutate fn after 3 sec delay
Run onSuccess and onSettled callbacks. - you will not be able to do this, just because these functions are called by RQ, so basically you will need to undo the mutate call which is not possible. You can try to abort it, but this way you will have desynchronization with your BE.
These steps show how you can mimic what you want. It's not a precise step-by-step guide.adverse-sapphireOP•2y ago
Actually, I've done this before in a generalized and reusable way:
Here I created a custom hook that is just like
useMutation with two differences:
1. It accepts an updateFn and onUndo which are supposed to do the Optimistic Update (update the cache directly) and undo the Optimistic Update respectively.
2. The mutate function that it returns does extra things like managing the undo notification.
It doesn't quite work. Because the onMutate context is not available when updateFn runs, which is necessary for my use-case. Other that that, I can't find a cleaner way to implement your idea.genetic-orange•2y ago
What do you mean by
onMutate context ?adverse-sapphireOP•2y ago
adverse-sapphireOP•2y ago
"The value returned from this function will be passed to both the onError and onSettled functions in the event of a mutation failure and can be useful for rolling back optimistic updates."
Here's an example of how this hook is used:
I want the
onMutate function be the only place that gets the data in the cache and provides it to onError, onSettled and updateFn. updateFn can use it for doing optimistic updates.genetic-orange•2y ago
You're trying to break the internals of RQ.
onError, onSettled are implementation details and they are run by RQ, not by you, so you can not change their behavior.
My initial proposal was fake it till you make it Canceling the query is not enough. Optimistic update works to completion or error, so either your mutation is successful or there is an error. In case of an error, you roll back the changes on the FE. There's no in-between state.
What do you think would happen if you cancel your request? It will be either success or error, canceling running request on the FE doesn't cancel it on the BE it simply discards of the response. So you will get FE that thinks the request was canceled and BE that proceed it.
Instead you should delay your .mutate call, you have all the data you need before triggering the request, so use it to fake the request and cancel it, before actually triggering mutate call. It's way simpler than trying to break into RQ internals and canceling real ongoing requestadverse-sapphireOP•2y ago
It's not about
onError and onSettled. Yes, they are managed by RQ.
I'm not sure we are on the same page. If you look the docs(https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates#updating-a-list-of-todos-when-adding-a-new-todo) this is the way they are suggesting to do Optimistic Updates. In an Optimistic Update, I have to update the cache manually. I should cancel the ongoing requests before doing that, so they don't override the cache after I updated it manually.
But I agree that I'm trying break the internals. I think the best way to resolve it is to give up on passing context to updateFn and read the old data that is needed for Optimistic Updates from setQueryData:
Given the current state of RQ, I guess it's not possible to do it better.Optimistic Updates | TanStack Query React Docs
React Query provides two ways to optimistically update your UI before a mutation has completed. You can either use the onMutate option to update your cache directly, or leverage the returned variables to update your UI from the useMutation result.
Via the UI
adverse-sapphireOP•2y ago
I have a lot of mutations that need to be undoable. That's why I'm trying to come up to a reusable hook which unfortunately needs breaking the internals. It certainly can be done better if I didn't have this requirement.
genetic-orange•2y ago
You misunderstand the docs. You cancel not a mutation itself, you cancel the ongoing refetch of
useQuery. You cancel all GET requests for [todos] because if you receive a todo list after you run your mutation you will end up with the wrong state, where your todo list will not include just added todo.adverse-sapphireOP•2y ago
By "ongoing requests" I meant "ongoing queries". My understanding of the docs is exactly what you said.
genetic-orange•2y ago
try to create smth like this
Instead of trying to break into RQ, try to create reusable hook that will delay a function that you provide to it for a given delay time.
You still gonna need to manually handle newly created item, but it's way easier than what you're trying to do.
adverse-sapphireOP•2y ago
I'm already doing this, but in another form.
genetic-orange•2y ago
From what I've seen so far you're monkeypatching the original
useMutation interface with your own set of additional options to create a reusable hook. That's where all the problems starts. It's not hook responsibility to show toast. It's a components logic.
My solution simply wraps mutate fn with setTimeout and provides your with a tool to simply cancel setTimeout call. You don't have to deal with all that queryClient stuff
At least I would do it like this, I honestly haven't seen any other solution so far on the internet.adverse-sapphireOP•2y ago
Thanks for the help.
genetic-orange•2y ago
I'm sorry that I couldn't come up with a better solution. That's probably not what you expected.
adverse-sapphireOP•2y ago
Absolutely no need to apologize! I came here for ideas, and that's exactly what I got. The brainstorming was incredibly valuable.
genetic-orange•2y ago
Also, to avoid messing with the cache altogether I would do something like this.
I hope this will be helpful 🙂
adverse-sapphireOP•2y ago
For my use-case updating the cache is better. Thanks for the suggestion though.
genetic-orange•2y ago
I have an idea. What if you try to call
onMutate function to provide context for updateFn. This way it will run twice, once for updateFn and second one for onError/onSettled. I guess it's no problem in your case. But you will need to check if onMutate returns a value or a promise and handle both of those cases.
You may also want to set variables to return them from your custom hook before the original mutate fn is called.adverse-sapphireOP•2y ago
I don't want to run
onMutate twice. I'm afraid of forgetting that it runs twice and do problematic things in it. It has potential to cause problems.genetic-orange•2y ago
Look,
onMutate returns a context for onError/onSettled. It is run whenever mutate fn is called, but you want to delay the call to the original mutate, so there is simply no context for onUpdate fn. If you want to go with this API, the only way to provide context is to manually call onMutate fn. Let's imagine what is the worst-case scenario for calling onMutate twice. It's almost always reading the cache and returning some values as a context. So in the worst case, you will read the cache twice, of course, it can have some performance implications, but in most cases it's fine. That's the only way you can continue with this API, or you should rethink your API.