Mục lục
Xem mục lục
Optimistic Update là gì
Optimistic Update là cập nhật UI ngay lập tức, dựa trên giả định thao tác sẽ thành công, thay vì đợi server xác nhận rồi mới hiển thị kết quả. Nếu request thất bại, UI được rollback về trạng thái trước đó.
Cách này đánh đổi độ chính xác tức thời lấy cảm giác phản hồi nhanh: người dùng thấy kết quả ngay khi bấm, không phải chờ round-trip mạng, dù bản chất dữ liệu thật vẫn chưa được server xác nhận tại thời điểm đó.
Cách làm thông thường (không optimistic)
Mặc định, một mutation chờ response rồi mới cập nhật UI:
function useToggleLike(postId) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => api.toggleLike(postId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["posts", postId] });
},
});
}
Người dùng bấm nút “Like”, nút vẫn giữ trạng thái cũ cho tới khi request xong (thường vài trăm mili-giây tới vài giây tùy mạng), rồi mới cập nhật lại UI theo dữ liệu mới từ invalidateQueries. Với mạng chậm, độ trễ này cảm nhận được rõ rệt, tạo cảm giác ứng dụng phản hồi chậm.
useMutation và onMutate
onMutate chạy ngay trước khi request được gửi đi, là nơi thực hiện optimistic update: ghi thẳng dữ liệu mới vào cache của React Query trước khi biết kết quả thật.
function useToggleLike(postId) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => api.toggleLike(postId),
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: ["posts", postId] });
queryClient.setQueryData(["posts", postId], old => ({
...old,
liked: !old.liked,
likeCount: old.liked ? old.likeCount - 1 : old.likeCount + 1,
}));
},
});
}
cancelQueries hủy các query đang chạy tới cùng queryKey, tránh tình huống một response cũ trả về sau và ghi đè lên optimistic update vừa thực hiện. setQueryData cập nhật thẳng vào cache, khiến UI re-render ngay lập tức với dữ liệu mới, dù request thật vẫn đang chạy ngầm.
Lưu lại snapshot để rollback
Nếu request thất bại, cần khôi phục cache về đúng trạng thái trước khi optimistic update. Muốn vậy phải lưu lại giá trị cũ trước khi ghi đè, thường trả về từ onMutate dưới dạng context:
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: ["posts", postId] });
const previousPost = queryClient.getQueryData(["posts", postId]);
queryClient.setQueryData(["posts", postId], old => ({
...old,
liked: !old.liked,
likeCount: old.liked ? old.likeCount - 1 : old.likeCount + 1,
}));
return { previousPost };
},
context (giá trị onMutate trả về) được React Query tự động truyền vào onError và onSettled, không cần lưu ở biến ngoài hay state riêng.
onError: rollback khi request thất bại
onError: (err, variables, context) => {
if (context?.previousPost) {
queryClient.setQueryData(["posts", postId], context.previousPost);
}
},
Khi mutationFn ném lỗi, React Query gọi onError với đúng context đã lưu ở onMutate, dùng nó để ghi đè cache về giá trị cũ, đưa UI trở lại trạng thái trước khi optimistic update, đúng như chưa từng có gì xảy ra.
onSettled: đồng bộ lại với server
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["posts", postId] });
},
onSettled luôn chạy sau cùng, bất kể mutation thành công hay thất bại. Gọi invalidateQueries ở đây đảm bảo cache cuối cùng luôn khớp với dữ liệu thật từ server, kể cả khi optimistic update đã đoán đúng (server có thể trả về dữ liệu hơi khác, ví dụ likeCount bị thay đổi bởi user khác cùng lúc).
Ví dụ đầy đủ: optimistic update cho danh sách todo
function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: todoId => api.toggleTodo(todoId),
onMutate: async todoId => {
await queryClient.cancelQueries({ queryKey: ["todos"] });
const previousTodos = queryClient.getQueryData(["todos"]);
queryClient.setQueryData(["todos"], old =>
old.map(todo =>
todo.id === todoId ? { ...todo, done: !todo.done } : todo
)
);
return { previousTodos };
},
onError: (err, todoId, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(["todos"], context.previousTodos);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
}
Sử dụng:
function TodoItem({ todo }) {
const toggleTodo = useToggleTodo();
return (
<label>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleTodo.mutate(todo.id)}
/>
{todo.title}
</label>
);
}
Checkbox đổi trạng thái ngay khi click, không đợi request. Nếu request thất bại (mất mạng, server lỗi), checkbox tự động trở lại trạng thái cũ nhờ onError, người dùng thấy rõ thao tác không thành công mà không cần thông báo lỗi riêng.
Khi nào nên và không nên dùng Optimistic Update
Nên dùng với thao tác:
- Tỷ lệ thất bại thấp trong điều kiện bình thường (like, bookmark, toggle trạng thái, đổi tên, kéo-thả sắp xếp).
- Hệ quả rollback nhẹ nhàng, không gây bối rối nếu UI đổi lại sau vài giây.
- Cần cảm giác phản hồi nhanh để trải nghiệm mượt (danh sách dài, thao tác lặp lại nhiều lần).
Không nên dùng với thao tác:
- Khả năng thất bại cao hoặc phụ thuộc điều kiện phức tạp ở server (thanh toán, đặt chỗ giới hạn số lượng, thao tác cần validate nghiêm ngặt).
- Hệ quả nghiêm trọng nếu phải rollback (xóa dữ liệu quan trọng, gửi email/thông báo không thể thu hồi).
- UI đổi qua đổi lại (optimistic rồi rollback) gây khó hiểu hơn là chỉ đơn giản hiển thị trạng thái loading và chờ kết quả thật.
Nguyên tắc chung: optimistic update phù hợp nhất với thao tác nhỏ, tần suất cao, ít rủi ro. Thao tác quan trọng, ít khi lặp lại, nên ưu tiên độ chính xác hơn tốc độ cảm nhận.
Tổng kết
- Optimistic Update cập nhật UI ngay trước khi server xác nhận, đổi lấy cảm giác phản hồi nhanh.
onMutatelà nơi ghi dữ liệu mới vào cache, nhớcancelQueriestrước để tránh response cũ ghi đè.- Luôn lưu snapshot dữ liệu cũ trong
onMutate, trả về quacontext, dùng để rollback trongonError. onSettledluôn chạy sau cùng, gọiinvalidateQueriesđể đảm bảo cache khớp đúng dữ liệu thật từ server.- Chỉ dùng Optimistic Update cho thao tác ít rủi ro, tần suất cao; tránh dùng cho thao tác quan trọng, khả năng thất bại cao hoặc hệ quả rollback nghiêm trọng.