useMutation calls will resolve for consumers after internal state like loading has already been updated
See original GitHub issueIntended outcome:
Given a simple component:
export const SimpleModal = (props: SimpleModalProps) => {
const { selection, onSuccess, open, onClose, onError } = props;
const [deleteShots, { loading: deleteShotsLoading }] = useMutation(DELETE_SHOTS);
const onConfirmDeleteSelected = useCallback(async () => {
try {
await deleteShots({ variables: { shotIds: Object.keys(selection.rowIds) } });
onSuccess?.();
} catch (error) {
onError?.(error);
}
}, [selection, onSuccess, onError, deleteShots]);
return (
<Modal open={open} onClose={onClose}>
<Button
onClick={onConfirmDeleteSelected}
disabled={deleteShotsLoading}
>
Delete Shots
</PrimaryButton>
</Modal>
);
};
I would expect that after deleteShots
succeeds, the internal state of the mutation has not yet updated. This is important since if it updates before I am able to take an action and the loading state becomes false, the button will actually become enabled again before I’ve called onSuccess
.
In this contrived scenario, if a user clicks on the button fast enough in between the time that loading moves to false and onSuccess
is called (which is closing the modal), they’ll trigger multiple mutations which will eventually result in an error because the entity that was deleted no longer exists.
Actual outcome:
The internal state is updated first: https://github.com/apollographql/apollo-client/blob/main/src/react/data/MutationData.ts#L73
So the loading state switches to “false”, and then the consumer code is executed (e.g. onSuccess
).
How to reproduce the issue:
I can reproduce if it’s helpful but the above snippet should be sufficient, and I first want to make sure there’s alignment on what the expected behavior should be before spending more time.
Workaround:
The obvious workaround is using my own separate loading state, but that feels a bit unfortunate:
const [loading, setLoading] = useState(false);
const onConfirmDeleteSelected = useCallback(async () => {
setLoading(true);
try {
await deleteShots({ variables: { shotIds: Object.keys(selection.rowIds) } });
onSuccess?.();
} catch (error) {
onError?.(error);
} finally {
setLoading(false);
}
}, [selection, onSuccess, onError, deleteShots]);
Possible Solutions:
If we were to wrap that call to onMutationCompleted
in a setTimeout
, the issue goes away, e.g:
return this.mutate(mutationFunctionOptions)
.then((response: FetchResult<TData>) => {
setTimeout(() => this.onMutationCompleted(response, mutationId));
return response;
})
However this behavior would almost certainly cause surprises or new behavior for many consumers so I’m not sure if it should be the default. I also don’t know what would be a sensible way to opt in to this behavior, it feels like a very weird feature, e.g. delayStateUpdate
?
Versions
System:
OS: macOS 11.2.2
Binaries:
Node: 12.18.4 - /usr/local/bin/node
Yarn: 1.22.4 - ~/npm-global/bin/yarn
npm: 6.14.6 - /usr/local/bin/npm
Browsers:
Chrome: 90.0.4430.93
Safari: 14.0.3
npmPackages:
@apollo/client: ^3.3.15 => 3.3.15
apollo-upload-client: 14.1.3 => 14.1.3
apollo3-cache-persist: 0.9.1 => 0.9.1
npmGlobalPackages:
apollo: 2.27.0
Issue Analytics
- State:
- Created 2 years ago
- Reactions:2
- Comments:5 (5 by maintainers)
Top GitHub Comments
Thanks for the response @brainkim !
That’s correct, I’m referring to loading / data / errors. And you have it right, it will cause a re-render before the promise settles, so my component is now acting on that state, which in the code snippet above, means the button becomes enabled again. Once the promise resolves, it calls onSuccess which is the consumer callback, which ends up closing the modal. So basically it’s an edge case where you can click super fast to cause multiple mutations.
We’d need to refactor in a lot of places. Basically we’re passing an entity ID to a modal, and the modal is responsible for removing that ID. We don’t have any knowledge of whether or not that entity still exists because we don’t ever write to the cache ourselves. I could instead make the disabled state check if the ID is still in the cache, but that only works if we’ve previously fetched the entity. What if the modal is deleting entities we don’t have fetched in the client? IMO I should be able to rely on the loading state to not re-render the component before the promise resolves, which will keep the button blocked. I also proposed another solution in my original comment where I maintain my own loading state, but that feels unfortunate and duplicative.
Yeah
setTimeout
was just an example to illustrate how you can defer but I agree we’d probably need something more robust. Maybe it could somehow be opt-in viadeferMutationUpdates
? That isn’t terribly descriptive but I’m not sure how to make it better.@brainkim another undesirable behavior here is that if you delete an entity ID, and that component has a “useQuery” which is getting information on that entityId, it will fire again before I have time to redirect to another page and prevent the “useQuery” from ever firing. Currently solve this by doing the
redirect
withinupdate
, e.g: