T
TanStack16mo ago
ambitious-aqua

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
adverse-sapphire16mo 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-aqua
ambitious-aquaOP16mo ago
Actually, I've done this before in a generalized and reusable way:
function useUndoableMutation(options, queryClient) {
const result = useMutation(options, queryClient);

function mutate(variables, mutateOptions) {
options.updateFn(variables, result.context);

toast.success("Success!", {
action: {
label: "UNDO",
onClick: () => {
options.onUndo(variables, result.context);
},
},
// runs after 3 seconds
onAutoClose: () => {
result.mutate(variables, mutateOptions});
},
});
};

return { ...result, mutate };
}
function useUndoableMutation(options, queryClient) {
const result = useMutation(options, queryClient);

function mutate(variables, mutateOptions) {
options.updateFn(variables, result.context);

toast.success("Success!", {
action: {
label: "UNDO",
onClick: () => {
options.onUndo(variables, result.context);
},
},
// runs after 3 seconds
onAutoClose: () => {
result.mutate(variables, mutateOptions});
},
});
};

return { ...result, mutate };
}
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
adverse-sapphire16mo ago
What do you mean by onMutate context ?
ambitious-aqua
ambitious-aquaOP16mo 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:
export function useOptimisticCreateUser() {
const queryClient = useQueryClient();

return useOptimisticMutation({
mutationFn: createUser,
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: ['users'] });

const oldData = queryClient.getQueryData(['users']);

return { oldData };
},
updateFn: (_variables, context) => {
if (context?.oldData) {
queryClient.setQueryData(['users'], [
...oldData,
variables,
]);
}
},
onUndo: (_variables, context) => {
if (context?.oldData) {
queryClient.setQueryData(['users'], context.oldData);
}
},
onError: (_error, _variables, context) => {
if (context?.oldData) {
queryClient.setQueryData(['users'], context.oldData);
}
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
export function useOptimisticCreateUser() {
const queryClient = useQueryClient();

return useOptimisticMutation({
mutationFn: createUser,
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: ['users'] });

const oldData = queryClient.getQueryData(['users']);

return { oldData };
},
updateFn: (_variables, context) => {
if (context?.oldData) {
queryClient.setQueryData(['users'], [
...oldData,
variables,
]);
}
},
onUndo: (_variables, context) => {
if (context?.oldData) {
queryClient.setQueryData(['users'], context.oldData);
}
},
onError: (_error, _variables, context) => {
if (context?.oldData) {
queryClient.setQueryData(['users'], context.oldData);
}
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
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
adverse-sapphire16mo 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 request
ambitious-aqua
ambitious-aquaOP16mo 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:
function useOptimisticCreateUser() {
const queryClient = useQueryClient();

return useOptimisticMutation({
// ...
updateFn: async (_variables) => {
await queryClient.cancelQueries({ queryKey: ['users'] });

queryClient.setQueryData(['users'], (oldData) => [ // RQ gives me the data that is in the cache if I use a function in `setQueryData`
...oldData,
variables,
]);
},
// ...
});
}
function useOptimisticCreateUser() {
const queryClient = useQueryClient();

return useOptimisticMutation({
// ...
updateFn: async (_variables) => {
await queryClient.cancelQueries({ queryKey: ['users'] });

queryClient.setQueryData(['users'], (oldData) => [ // RQ gives me the data that is in the cache if I use a function in `setQueryData`
...oldData,
variables,
]);
},
// ...
});
}
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-aqua
ambitious-aquaOP16mo 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
adverse-sapphire16mo 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-aqua
ambitious-aquaOP16mo ago
By "ongoing requests" I meant "ongoing queries". My understanding of the docs is exactly what you said.
adverse-sapphire
adverse-sapphire16mo ago
try to create smth like this
function App(){
const mutation = useMutation();
const { mutate, cancel } = useDelayedMutation(mutation.mutate, delay);

const handleSubmit = () => {
mutate(someData);

toast.success("Success!", {
action: {
label: "UNDO",
onClick: () => cancel(),
},
});
}

return (
<div>
<button onClick={handleSubmit}>Submit</button>
</div>
)

}
function App(){
const mutation = useMutation();
const { mutate, cancel } = useDelayedMutation(mutation.mutate, delay);

const handleSubmit = () => {
mutate(someData);

toast.success("Success!", {
action: {
label: "UNDO",
onClick: () => cancel(),
},
});
}

return (
<div>
<button onClick={handleSubmit}>Submit</button>
</div>
)

}
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-aqua
ambitious-aquaOP16mo ago
I'm already doing this, but in another form.
adverse-sapphire
adverse-sapphire16mo 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-aqua
ambitious-aquaOP16mo ago
Thanks for the help.
adverse-sapphire
adverse-sapphire16mo ago
I'm sorry that I couldn't come up with a better solution. That's probably not what you expected.
ambitious-aqua
ambitious-aquaOP16mo 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
adverse-sapphire16mo ago
Also, to avoid messing with the cache altogether I would do something like this.
function App(){
const todos = useTodos();
const [newTodo, setNewTodo] = useState(null);

....

const handleSubmit = (todo) => {
setNewTodo(todo)
mutate(todo);

toast.success("Success!", {
action: {
label: "UNDO",
onClick: () => cancel().then(() => setNewTodo(null)),
},
});
}

return (
<div>
{todos.map(todo => <div>{todo}</div>)}
{newTodo && <div>{newTodo}</div>}

<button onClick={handleSubmit}>Submit</button>
</div>
)

}
function App(){
const todos = useTodos();
const [newTodo, setNewTodo] = useState(null);

....

const handleSubmit = (todo) => {
setNewTodo(todo)
mutate(todo);

toast.success("Success!", {
action: {
label: "UNDO",
onClick: () => cancel().then(() => setNewTodo(null)),
},
});
}

return (
<div>
{todos.map(todo => <div>{todo}</div>)}
{newTodo && <div>{newTodo}</div>}

<button onClick={handleSubmit}>Submit</button>
</div>
)

}
I hope this will be helpful 🙂
ambitious-aqua
ambitious-aquaOP16mo ago
For my use-case updating the cache is better. Thanks for the suggestion though.
adverse-sapphire
adverse-sapphire16mo 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.
function useUndoableMutation(options, queryClient) {
const result = useMutation(options, queryClient);

function mutate(variables, mutateOptions) {
const context = options.onMutate(variables);
options.updateFn(variables, context);

...
};

return { ...result, mutate };
}
function useUndoableMutation(options, queryClient) {
const result = useMutation(options, queryClient);

function mutate(variables, mutateOptions) {
const context = options.onMutate(variables);
options.updateFn(variables, context);

...
};

return { ...result, mutate };
}
You may also want to set variables to return them from your custom hook before the original mutate fn is called.
ambitious-aqua
ambitious-aquaOP16mo 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
adverse-sapphire16mo 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.

Did you find this page helpful?