Cập nhật Optimistic UI trong React giúp ứng dụng có cảm giác tức thì. Tìm hiểu các mẫu an toàn để hòa giải với server, xử lý lỗi và ngăn drift dữ liệu.

Optimistic UI trong React có nghĩa là bạn cập nhật màn hình như thể thay đổi đã thành công, trước khi server xác nhận. Ai đó bấm Like, số lượt tăng ngay lập tức, và request chạy ở nền.
Phản hồi tức thì đó làm ứng dụng có cảm giác nhanh. Trên mạng chậm, đó thường là sự khác biệt giữa “nhanh mượt” và “nó có thực sự được không?”
Đổi lại là drift dữ liệu: những gì người dùng thấy có thể dần không khớp với chân lý trên server. Drift thường xuất hiện dưới dạng những bất nhất nhỏ khó chịu phụ thuộc vào thời điểm và khó tái hiện.
Người dùng thường nhận ra drift khi mọi thứ “thay đổi ý” sau đó: một bộ đếm nhảy rồi bật lại, một mục xuất hiện rồi biến mất sau khi reload, một chỉnh sửa có vẻ đã lưu cho đến khi bạn quay lại trang, hoặc hai tab hiển thị giá trị khác nhau.
Điều này xảy ra vì UI đang đoán, và server có thể trả về sự thật khác. Các quy tắc xác thực, dedupe, kiểm tra quyền, giới hạn tần suất, hoặc một thiết bị khác thay đổi cùng bản ghi đều có thể thay đổi kết quả cuối cùng. Một nguyên nhân phổ biến nữa là các request chồng lên nhau: một phản hồi cũ đến sau cùng và ghi đè hành động mới hơn của người dùng.
Ví dụ: bạn đổi tên project thành “Q1 Plan” và hiển thị ngay trong header. Server có thể trim khoảng trắng, từ chối ký tự, hoặc sinh slug. Nếu bạn không thay giá trị optimistic bằng giá trị cuối cùng từ server, UI trông đúng cho tới lần reload tiếp theo, khi nó “thay đổi bí ẩn”.
Optimistic UI không phải lúc nào cũng là lựa chọn đúng. Hãy thận trọng (hoặc tránh) với tiền và thanh toán, hành động không thể hoàn tác, thay đổi quyền, workflow có quy tắc server phức tạp, hoặc bất cứ thứ gì có tác dụng phụ người dùng cần xác nhận rõ ràng.
Dùng đúng, optimistic updates làm app cảm giác tức thì, nhưng chỉ khi bạn lên kế hoạch cho hòa giải, sắp xếp thứ tự và xử lý lỗi.
Optimistic UI hoạt động tốt nhất khi bạn phân tách hai loại state:
Hầu hết drift bắt đầu khi một dự đoán cục bộ bị xử lý như sự thật đã xác nhận.
Một quy tắc đơn giản: nếu một giá trị có ý nghĩa nghiệp vụ ngoài màn hình hiện tại, server là nguồn chân lý. Nếu nó chỉ ảnh hưởng cách màn hình hành xử (mở/đóng, input đang focus, text nháp), hãy giữ nó cục bộ.
Trong thực tế, giữ server truth cho những thứ như quyền, giá, số dư, tồn kho, trường tính toán hoặc xác thực, và bất cứ thứ gì có thể thay đổi ở nơi khác (tab khác, user khác). Giữ local UI state cho bản nháp, flag “is editing”, filter tạm thời, hàng mở rộng và toggle animation.
Một số hành động “an toàn để đoán” vì server hầu như luôn chấp nhận và dễ hoàn tác, như gắn sao một mục hoặc bật/tắt preference đơn giản.
Khi một trường không an toàn để đoán, bạn vẫn có thể làm app cảm thấy nhanh mà không giả vờ thay đổi là cuối cùng. Giữ giá trị đã được xác nhận cuối cùng, và thêm tín hiệu pending rõ ràng.
Ví dụ, trên màn CRM nơi bạn bấm “Mark as paid”, server có thể từ chối (quyền, xác thực, đã refund). Thay vì ngay lập tức viết lại mọi con số dẫn xuất, hãy cập nhật trạng thái với một nhãn nhỏ “Saving…”, giữ tổng số không đổi, và chỉ cập nhật tổng số sau khi có xác nhận.
Các mẫu tốt đơn giản và nhất quán: một huy hiệu nhỏ “Saving…” gần mục thay đổi, tạm thời vô hiệu hóa hành động (hoặc biến nó thành Undo) cho tới khi request hoàn tất, hoặc đánh dấu giá trị optimistic là tạm thời (chữ mờ nhẹ hoặc spinner nhỏ).
Nếu phản hồi server có thể ảnh hưởng nhiều nơi (tổng số, sắp xếp, trường tính toán, quyền), refetch thường an toàn hơn cố gắng patch mọi thứ. Nếu đó là thay đổi nhỏ, cô lập (đổi tên note, toggle flag), patch cục bộ thường ổn.
Một quy tắc hữu dụng: patch đúng thứ người dùng thay đổi, rồi refetch bất kỳ dữ liệu nào được dẫn xuất, tổng hợp, hoặc chia sẻ giữa các màn.
Optimistic UI hoạt động khi mô hình dữ liệu của bạn theo dõi rõ ràng cái gì đã được xác nhận và cái gì vẫn là dự đoán. Nếu bạn mô hình hóa khoảng cách đó rõ ràng, các khoảnh khắc “tại sao nó quay lại?” sẽ hiếm xảy ra.
Với các mục mới tạo, cấp một client ID tạm thời (như temp_12345 hoặc UUID), rồi đổi sang ID thật của server khi phản hồi về. Điều này cho phép danh sách, selection và trạng thái chỉnh sửa hòa giải gọn gàng.
Ví dụ: người dùng thêm một task. Bạn render ngay với id: "temp_a1". Khi server trả về id: 981, bạn thay ID ở một chỗ, và mọi thứ keyed theo ID vẫn hoạt động.
Một cờ loading ở cấp màn quá thô. Theo dõi trạng thái trên item (hoặc thậm chí trên từng field) đang thay đổi. Bằng cách đó bạn có thể hiển thị UI pending tinh tế, retry chỉ cái thất bại, và tránh khóa các hành động không liên quan.
Một hình dạng item thực tế:
id: thật hoặc tạm thờistatus: pending | confirmed | failedoptimisticPatch: những gì bạn đã thay đổi cục bộ (nhỏ và cụ thể)serverValue: dữ liệu xác nhận cuối cùng (hoặc confirmedAt timestamp)rollbackSnapshot: giá trị đã xác nhận trước đó bạn có thể khôi phụcOptimistic updates an toàn nhất khi bạn chỉ chạm vào đúng thứ người dùng thay đổi (ví dụ toggle completed) thay vì thay cả object bằng một “phiên bản mới” đoán trước. Thay thế cả object dễ xóa mất các chỉnh sửa mới hơn, các trường do server thêm, hoặc các thay đổi đồng thời.
Một optimistic update tốt có cảm giác tức thì, nhưng cuối cùng vẫn khớp với server. Coi thay đổi optimistic như tạm thời, và giữ đủ bookkeeping để xác nhận hoặc undo an toàn.
Ví dụ: người dùng sửa tiêu đề task trong danh sách. Bạn muốn tiêu đề cập nhật ngay, nhưng cũng cần xử lý lỗi validate và định dạng phía server.
Áp dụng thay đổi optimistic ngay trong local state. Lưu một patch nhỏ (hoặc snapshot) để có thể hoàn tác.
Gửi request kèm request ID (số tăng dần hoặc ID ngẫu nhiên). Đây là cách bạn ghép phản hồi với hành động kích hoạt nó.
Đánh dấu item là pending. Pending không nhất thiết phải chặn UI. Nó có thể là spinner nhỏ, chữ mờ, hoặc “Saving…”. Điều quan trọng là người dùng hiểu nó chưa được xác nhận.
Khi thành công, thay dữ liệu client tạm thời bằng phiên bản server. Nếu server hiệu chỉnh gì (trim khoảng trắng, đổi hoa/thường, cập nhật timestamp), cập nhật local state cho khớp.
Khi thất bại, chỉ revert những gì request này thay đổi và hiện lỗi cục bộ rõ ràng. Tránh rollback những phần không liên quan trên màn.
Dưới đây là một hình dạng nhỏ bạn có thể theo (không phụ thuộc thư viện):
const requestId = crypto.randomUUID();
applyOptimistic({ id, title: nextTitle, pending: requestId });
try {
const serverItem = await api.updateTask({ id, title: nextTitle, requestId });
confirmSuccess({ id, requestId, serverItem });
} catch (err) {
rollback({ id, requestId });
showError("Could not save. Your change was undone.");
}
Hai chi tiết ngăn nhiều bug: lưu request ID trên item khi nó pending, và chỉ confirm hoặc rollback nếu IDs khớp. Điều đó ngăn phản hồi cũ ghi đè chỉnh sửa mới hơn.
Optimistic UI gặp vấn đề khi mạng trả lời không theo thứ tự. Một lỗi kinh điển: người dùng sửa tiêu đề, sửa lại ngay, và request đầu xuất hiện muộn hơn. Nếu bạn áp dụng phản hồi muộn đó, UI sẽ bật lại giá trị cũ.
Cách sửa là coi mọi phản hồi là “có thể liên quan” và chỉ áp dụng khi nó khớp với ý định mới nhất của người dùng.
Một mẫu thực tế là gắn client request ID (counter) cho mỗi thay đổi optimistic. Lưu ID mới nhất theo từng record. Khi phản hồi tới, so sánh ID. Nếu phản hồi cũ hơn ID mới nhất, bỏ qua.
Kiểm tra phiên bản cũng hữu ích. Nếu server trả về updatedAt, version, hoặc etag, chỉ chấp nhận phản hồi mới hơn so với những gì UI đang hiển thị.
Các lựa chọn khác bạn có thể kết hợp:
Ví dụ (bảo vệ bằng request ID):
let nextId = 1;
const latestByItem = new Map();
async function saveTitle(itemId, title) {
const requestId = nextId++;
latestByItem.set(itemId, requestId);
// optimistic update
setItems(prev => prev.map(i => i.id === itemId ? { ...i, title } : i));
const res = await api.updateItem(itemId, { title, requestId });
// ignore stale response
if (latestByItem.get(itemId) !== requestId) return;
// reconcile with server truth
setItems(prev => prev.map(i => i.id === itemId ? { ...i, ...res.item } : i));
}
Nếu người dùng gõ nhanh (notes, titles, search), cân nhắc hủy hoặc trì hoãn save cho tới khi họ dừng gõ. Điều này giảm tải server và giảm khả năng các phản hồi đến muộn gây nhấp nháy.
Lỗi là nơi optimistic UI có thể làm mất niềm tin. Trải nghiệm tệ nhất là rollback đột ngột mà không có giải thích.
Mặc định tốt cho chỉnh sửa là: giữ giá trị của người dùng trên màn, đánh dấu là chưa lưu, và hiện lỗi inline ngay chỗ họ sửa. Nếu ai đó đổi tên project từ “Alpha” thành “Q1 Launch”, đừng bật lại về “Alpha” trừ khi bắt buộc. Giữ “Q1 Launch”, thêm “Not saved. Name already taken,” và để họ sửa.
Phản hồi inline gắn với đúng field hoặc row lỗi. Nó tránh khoảnh khắc “vừa rồi xảy ra chuyện gì?” khi một toast hiện lên nhưng UI lặng lẽ thay đổi lại.
Các dấu hiệu đáng tin cậy gồm “Saving…” khi đang gửi, “Not saved” khi lỗi, làm nổi bật nhẹ hàng bị ảnh hưởng, và một thông điệp ngắn nói người dùng nên làm gì tiếp theo.
Retry hầu như luôn hữu ích. Undo tốt cho các hành động nhanh mà người ta có thể hối hận (như archive), nhưng có thể gây nhầm lẫn với các chỉnh sửa nơi người dùng rõ ràng muốn giá trị mới.
Khi mutation thất bại:
Nếu buộc phải rollback (ví dụ quyền thay đổi và người dùng không còn quyền), giải thích lý do và khôi phục server truth: “Couldn’t save. You no longer have access to edit this.”
Coi phản hồi server như biên lai, không chỉ là flag thành công. Sau khi request hoàn tất, hòa giải: giữ lại ý định của người dùng, và chấp nhận những gì server biết rõ hơn.
Refetch toàn bộ an toàn nhất khi server có thể thay đổi nhiều hơn dự đoán cục bộ. Nó cũng dễ suy luận hơn.
Refetch thường là lựa chọn tốt khi mutation ảnh hưởng nhiều bản ghi (di chuyển mục giữa các danh sách), khi quyền hoặc quy tắc workflow có thể thay đổi kết quả, khi server trả về dữ liệu một phần, hoặc khi client khác cập nhật cùng view thường xuyên.
Nếu server trả về thực thể đã cập nhật (hoặc đủ trường), merge có thể mang lại trải nghiệm tốt hơn: UI ổn định trong khi vẫn chấp nhận server truth.
Drift thường đến từ việc ghi đè các trường do server quản lý bằng một object optimistic. Nghĩ tới bộ đếm, giá trị tính toán, timestamp và định dạng chuẩn hóa.
Ví dụ: bạn optimistic thiết lập likedByMe=true và tăng likeCount. Server có thể dedupe lượt like kép và trả về likeCount khác, cộng thêm updatedAt mới.
Cách merge đơn giản:
Khi có xung đột, quyết trước. “Last write wins” ổn với các toggle đơn giản. Merge theo trường tốt hơn cho form.
Theo dõi một flag per-field “dirty since request” (hoặc số phiên bản local) cho phép bạn bỏ qua giá trị server cho những field người dùng thay đổi sau khi mutation bắt đầu, trong khi vẫn chấp nhận server truth cho phần còn lại.
Nếu server từ chối mutation, ưu tiên lỗi cụ thể, nhẹ nhàng hơn là rollback bất ngờ. Giữ input của người dùng, highlight field, và hiển thị thông báo. Rollback chỉ dùng khi hành động thực sự không thể đứng vững (ví dụ bạn đã optimistic xóa một mục mà server từ chối xóa).
Danh sách là nơi optimistic UI rất thích hợp nhưng cũng dễ hỏng. Một mục thay đổi có thể ảnh hưởng thứ tự, tổng số, bộ lọc và nhiều trang.
Với tạo mới, hiển thị mục mới ngay nhưng đánh dấu nó pending với ID tạm thời. Giữ vị trí ổn định để nó không nhảy.
Với xóa, mẫu an toàn là ẩn mục ngay lập tức nhưng giữ một “ghost” tạm trong bộ nhớ cho tới khi server xác nhận. Điều này hỗ trợ undo và xử lý lỗi dễ hơn.
Sắp xếp lại khó vì tác động tới nhiều mục. Nếu bạn optimistic reorder, lưu thứ tự trước đó để khôi phục khi cần.
Với pagination hoặc infinite scroll, quyết nơi chèn optimistic. Trong feed, item mới thường lên đầu. Trong catalog sắp xếp bởi server, chèn cục bộ có thể gây hiểu nhầm vì server có thể đặt item ở chỗ khác. Thỏa hiệp thực tế là chèn vào danh sách đang nhìn thấy với huy hiệu pending, rồi sẵn sàng di chuyển sau phản hồi server nếu khóa sắp xếp cuối cùng khác.
Khi temp ID thành ID thật, dedupe bằng khóa ổn định. Nếu chỉ đối chiếu theo ID, bạn có thể thấy cùng mục hai lần (temp và confirmed). Giữ ánh xạ tempId->realId và thay thế tại chỗ để vị trí cuộn và selection không bị reset.
Counts và bộ lọc cũng là trạng thái danh sách. Cập nhật counts một cách optimistic chỉ khi bạn tự tin server sẽ đồng ý. Nếu không, đánh dấu chúng là đang làm mới và hòa giải sau phản hồi.
Phần lớn bug optimistic-update không thực sự do React. Chúng đến từ việc coi thay đổi optimistic là “sự thật mới” thay vì một dự đoán tạm thời.
Optimistically cập nhật cả object hoặc cả màn khi chỉ một field thay đổi làm tăng vùng ảnh hưởng. Sửa chữa từ server sau đó có thể ghi đè các chỉnh sửa không liên quan.
Ví dụ: một form profile thay thế toàn bộ object user khi bạn toggle một setting. Trong khi request đang chờ, user chỉnh tên. Khi phản hồi về, replace có thể đặt lại tên cũ.
Giữ patch optimistic nhỏ và tập trung.
Một nguồn drift nữa là quên xóa flag pending sau khi success hoặc error. UI giữ trạng thái nửa tải, và logic sau đó có thể coi nó vẫn optimistic.
Nếu bạn theo dõi pending per item, xóa nó bằng cùng key đã dùng để set. Temp ID thường gây “pending ghost” khi ID thật không được ánh xạ mọi nơi.
Bug rollback xảy ra khi snapshot lưu quá muộn hoặc phạm vi quá rộng.
Nếu user thực hiện hai chỉnh sửa nhanh, bạn có thể rollback edit #2 dùng snapshot trước edit #1. UI bật về trạng thái người dùng chưa từng thấy.
Sửa: snapshot chính xác phần bạn sẽ khôi phục, và gắn nó với một lần mutation cụ thể (thường dùng request ID).
Lưu thực tế thường đa bước. Nếu bước 2 thất bại (ví dụ upload ảnh), đừng âm thầm undo bước 1. Hiển thị cái gì đã lưu, cái gì chưa và hành động người dùng có thể làm tiếp theo.
Ngoài ra, đừng giả định server sẽ echo lại chính xác những gì bạn gửi. Server chuẩn hóa text, áp quyền, set timestamp, gán ID và loại bỏ trường. Luôn hòa giải từ phản hồi (hoặc refetch) thay vì tin mãi vào optimistic patch.
Optimistic UI hoạt động khi nó có thể dự đoán được. Hãy coi mỗi thay đổi optimistic như một mini transaction: có ID, trạng thái pending hiển thị, swap rõ ràng khi thành công, và đường dẫn lỗi không làm người dùng ngạc nhiên.
Checklist trước khi ra mắt:
Nếu bạn đang prototype nhanh, giữ phiên bản đầu nhỏ: một màn, một mutation, một cập nhật danh sách. Công cụ như Koder.ai (koder.ai) có thể giúp bạn phác thảo UI và API nhanh hơn, nhưng quy tắc vẫn vậy: mô hình pending vs confirmed để client không bao giờ mất dấu cái server thực sự chấp nhận.
Optimistic UI cập nhật giao diện ngay lập tức, trước khi server xác nhận thay đổi. Nó làm cho app có cảm giác tức thời, nhưng bạn vẫn phải hòa giải với phản hồi từ server để UI không bị lệch so với trạng thái đã lưu thực sự.
Drift dữ liệu xảy ra khi UI giữ một dự đoán optimistic như thể nó đã được xác nhận, nhưng server lại lưu khác đi hoặc từ chối. Thường xuất hiện sau khi refresh, ở tab khác, hoặc khi mạng chậm khiến phản hồi đến không theo thứ tự.
Tránh hoặc rất thận trọng với optimistic updates cho tiền và thanh toán, hành động không thể hoàn tác, thay đổi quyền, và các luồng có nhiều quy tắc server. Với những trường hợp này, mặc định an toàn hơn là hiển thị trạng thái pending rõ ràng và chờ xác nhận trước khi thay đổi những thứ ảnh hưởng đến tổng số hoặc quyền truy cập.
Xem backend là nguồn chân lý cho mọi thứ có ý nghĩa nghiệp vụ ngoài màn hình hiện tại, như giá, quyền, trường tính toán, và bộ đếm chia sẻ. Giữ state UI cục bộ cho bản nháp, focus, "is editing", bộ lọc và các trạng thái chỉ phục vụ hiển thị.
Hiển thị một tín hiệu nhỏ, nhất quán ngay vị trí thay đổi, như “Saving…”, văn bản mờ hoặc một spinner nhẹ. Mục tiêu là cho người dùng biết giá trị chưa được xác nhận mà không chặn cả trang.
Dùng một client ID tạm thời (ví dụ UUID hoặc temp_...) khi tạo mục, rồi thay bằng ID thật của server khi thành công. Cách này giữ cho khóa danh sách, selection và trạng thái chỉnh sửa ổn định để mục không bị nhấp nháy hoặc trùng lặp.
Đừng dùng cờ loading toàn màn. Theo dõi pending per item (hoặc per field) để chỉ đúng phần thay đổi hiển thị là đang chờ. Lưu một optimistic patch nhỏ và một rollback snapshot để bạn có thể xác nhận hoặc hoàn tác chỉ thay đổi đó mà không ảnh hưởng UI khác.
Gắn request ID cho mỗi mutation và lưu request ID mới nhất theo từng item. Khi phản hồi tới, chỉ áp dụng nếu khớp request ID mới nhất; nếu không, bỏ qua để phản hồi cũ không làm UI quay về trạng thái cũ.
Với hầu hết chỉnh sửa, giữ giá trị của người dùng trên màn hình, đánh dấu là chưa lưu và hiện lỗi inline tại chỗ họ đã sửa, kèm tùy chọn Retry rõ ràng. Chỉ rollback cứng khi hành động thực sự không thể giữ (ví dụ mất quyền), và giải thích lý do.
Refetch khi thay đổi có thể ảnh hưởng nhiều nơi như tổng số, sắp xếp, quyền, hoặc các trường dẫn xuất, vì patch thủ công dễ sai. Hợp nhất (merge) cục bộ khi đó là cập nhật nhỏ, cô lập và server trả về thực thể đã cập nhật; sau đó xóa trạng thái pending và chấp nhận các trường do server quản lý như timestamp và giá trị tính toán.