T
TanStack•8mo ago
sensitive-blue

laggy UI when using optimistic update

Hi guys, I'm using the optimistic update technique. But sometimes, the ui is updating slowly. I'm not sure where I was wrong. Please take a look at my video and my custom mutation. Can anyone help me?
9 Replies
sensitive-blue
sensitive-blueOP•8mo ago
export const useTogglePermission = () => {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (
payload: PayloadAddPermissionToRole & {
isAdd: boolean;
newPermission: Permission;
},
) =>
payload.isAdd
? roles.addPermission(payload)
: roles.deletePermission(payload),
onMutate: async (payload) => {
await queryClient.cancelQueries({
queryKey: ["roles", "detail", payload.id],
});
toast.success(
`${payload.isAdd ? "Add" : "Remove"} permission successfully`,
);
const snapshot = queryClient.getQueryData(
["roles", "detail", payload.id],
);

queryClient.setQueryData<ResponseRoleDetail>(
["roles", "detail", payload.id],
(oldData) => {
if (!oldData) return undefined;
return {
...oldData,
permissions: payload.isAdd
? [payload.newPermission, ...oldData.permissions]
: oldData.permissions.filter(
(p) => p.id !== payload.permissionId,
),
};
},
);

return () => {
queryClient.setQueryData(["roles", "detail", payload.id], snapshot);
};
},
onError: (err, payload, rollback) => {
const permissionAction = capitalize(
payload.newPermission.action.slice(0, -4),
);
const permissionSubject = capitalize(
payload.newPermission.permissionSubject.name,
);
toast.error(
err?.message ||
`Fail to ${payload.isAdd ? "add" : "remove"} ${permissionAction} to ${permissionSubject}`,
);
rollback?.();
},
onSettled: (_, err, payload) => {
return queryClient.invalidateQueries({
queryKey: ["roles", "detail", payload.id],
});
},
});
};
export const useTogglePermission = () => {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (
payload: PayloadAddPermissionToRole & {
isAdd: boolean;
newPermission: Permission;
},
) =>
payload.isAdd
? roles.addPermission(payload)
: roles.deletePermission(payload),
onMutate: async (payload) => {
await queryClient.cancelQueries({
queryKey: ["roles", "detail", payload.id],
});
toast.success(
`${payload.isAdd ? "Add" : "Remove"} permission successfully`,
);
const snapshot = queryClient.getQueryData(
["roles", "detail", payload.id],
);

queryClient.setQueryData<ResponseRoleDetail>(
["roles", "detail", payload.id],
(oldData) => {
if (!oldData) return undefined;
return {
...oldData,
permissions: payload.isAdd
? [payload.newPermission, ...oldData.permissions]
: oldData.permissions.filter(
(p) => p.id !== payload.permissionId,
),
};
},
);

return () => {
queryClient.setQueryData(["roles", "detail", payload.id], snapshot);
};
},
onError: (err, payload, rollback) => {
const permissionAction = capitalize(
payload.newPermission.action.slice(0, -4),
);
const permissionSubject = capitalize(
payload.newPermission.permissionSubject.name,
);
toast.error(
err?.message ||
`Fail to ${payload.isAdd ? "add" : "remove"} ${permissionAction} to ${permissionSubject}`,
);
rollback?.();
},
onSettled: (_, err, payload) => {
return queryClient.invalidateQueries({
queryKey: ["roles", "detail", payload.id],
});
},
});
};
noble-gold
noble-gold•8mo ago
@TkDodo 🔮 Hey man, is it even ok to swap mutationFn like this:
mutationFn: (
payload: PayloadAddPermissionToRole & {
isAdd: boolean;
newPermission: Permission;
},
) =>
payload.isAdd
? roles.addPermission(payload)
: roles.deletePermission(payload),
mutationFn: (
payload: PayloadAddPermissionToRole & {
isAdd: boolean;
newPermission: Permission;
},
) =>
payload.isAdd
? roles.addPermission(payload)
: roles.deletePermission(payload),
I thought it should be static and have a stable reference?!
typical-coral
typical-coral•8mo ago
concurrent optimistic updates are hard. it's a race condition between writing to the cache and invalidation. that's why we have cancelQueries in onMutate, but it's sometimes not enough. It prevents this situation: - user clicks, write to cache - mutation finishes, invalidate - user clicks now, the cancellation cancels the ongoing invalidate because that would overwrite the optimistic update. That's great. However, consider this situation: - user clicks, write to cache - mutation1 starts - user clicks another field, write to cache again - mutation2 starts - mutation1 finishes, invalidate - invalidation finishes, killing the optimistic update of mutation2 This is likely what's happening to you. The way to fix (which I'm also teaching in my course btw) is to not invaliadate while a related mutation is happening. Try this for starters:
onSetteled: () => {
if (queryClient.isMutating() === 1) {
queryClient.invalidateQueries(...)
}
}
onSetteled: () => {
if (queryClient.isMutating() === 1) {
queryClient.invalidateQueries(...)
}
}
this will only make an invalidation for the "last" mutation that is happening. If you want to narrow things down to "related mutations", give your mutation a mutationKey and pass that to isMutating as a filter. This is a good topic for a blogpost I guess 😂
noble-gold
noble-gold•8mo ago
Absolutely agree with you, I basically learnt react-query by reading your blog post. Ten times more informative than the docs, even though the docs are great, but lack a reasoning behind the solutions and some pitfalls and common tricks 🙂
typical-coral
typical-coral•8mo ago
Writing the blogpost now. Did my suggestion solve your problem?
noble-gold
noble-gold•8mo ago
It wasn't my problem, but I come to the same conclusion as you. @noob Did it help to solve your problem?
typical-coral
typical-coral•8mo ago
oh sorry, I didn't correctly check if you are OP 🙈
noble-gold
noble-gold•8mo ago
no worries 🙂
sensitive-blue
sensitive-blueOP•7mo ago
Sry guys, I'm on holiday. I'll try it when i'm back to work It works perfectly when I check queryClient.isMutating() === 1 before invalidating the query. Thanks guys

Did you find this page helpful?