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
adverse-sapphire•16mo 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.ambitious-aquaOP•16mo 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.adverse-sapphire•16mo ago
What do you mean by
onMutate context
?ambitious-aquaOP•16mo ago
ambitious-aquaOP•16mo 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.adverse-sapphire•16mo 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 requestambitious-aquaOP•16mo 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
ambitious-aquaOP•16mo 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.
adverse-sapphire•16mo 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.ambitious-aquaOP•16mo ago
By "ongoing requests" I meant "ongoing queries". My understanding of the docs is exactly what you said.
adverse-sapphire•16mo 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.
ambitious-aquaOP•16mo ago
I'm already doing this, but in another form.
adverse-sapphire•16mo 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.ambitious-aquaOP•16mo ago
Thanks for the help.
adverse-sapphire•16mo ago
I'm sorry that I couldn't come up with a better solution. That's probably not what you expected.
ambitious-aquaOP•16mo ago
Absolutely no need to apologize! I came here for ideas, and that's exactly what I got. The brainstorming was incredibly valuable.
adverse-sapphire•16mo ago
Also, to avoid messing with the cache altogether I would do something like this.
I hope this will be helpful 🙂
ambitious-aquaOP•16mo ago
For my use-case updating the cache is better. Thanks for the suggestion though.
adverse-sapphire•16mo 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.ambitious-aquaOP•16mo 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.adverse-sapphire•16mo 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.