React Optimistic Update: Hiển thị reply ngay mà không reload trang
React Optimistic Update: Hiển thị reply ngay mà không reload trang
Người dùng gõ xong nội dung reply, nhấn Gửi, rồi… toàn bộ trang tải lại. Scroll bật về đầu. Mất hết vị trí đang đọc. Phải kéo xuống cuối thread để thấy reply vừa gửi.
Đây là pattern hay gặp khi mới làm quen với React: gọi API POST xong thì refetch() toàn bộ danh sách. Hợp lý về mặt lý thuyết — dữ liệu mới nhất từ server. Nhưng trải nghiệm người dùng thì tệ, đặc biệt trong hệ thống helpdesk có thread dài với nhiều reply.
Triệu chứng
// Pattern phổ biến nhưng gây vấn đề UX
const handleSubmit = async (content) => {
await api.post(`/tickets/${ticketId}/reply`, { content });
await refetch(); // ← đây là vấn đề
};
Khi refetch() chạy:
- Component re-render với
loading: true— danh sách reply biến mất tạm thời. - Request đến server, nhận về toàn bộ reply list.
- Component re-render với dữ liệu mới — danh sách hiện trở lại.
scrollTopbị reset — thường không ở vị trí reply mới nhất.
Với thread có 30-50 reply, khoảng thời gian từ bước 1 đến bước 3 trông như trang load lại — không khác gì MPA (Multi-Page Application).
Pattern đúng: Optimistic Update
Thay vì chờ server xác nhận rồi mới cập nhật UI, append reply vào local state ngay lập tức khi người dùng nhấn Gửi. Nếu request thất bại mới rollback.
Đây là “optimistic update” — tin tưởng rằng request sẽ thành công (và thực tế 99% là vậy), nên cập nhật UI trước, đồng bộ server sau.
const [replies, setReplies] = useState([]);
const bottomRef = useRef(null);
const handleSubmit = async (content) => {
// 1. Tạo reply tạm với id giả để render ngay
const tempReply = {
id: `temp-${Date.now()}`,
content,
author: currentUser.name,
created_at: new Date().toISOString(),
is_pending: true,
};
// 2. Append vào danh sách — UI cập nhật ngay, không reload
setReplies((prev) => [...prev, tempReply]);
// 3. Scroll xuống reply mới
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
// 4. Gửi request thật lên server
try {
const saved = await api.post(`/tickets/${ticketId}/reply`, { content });
// 5. Thay thế temp reply bằng dữ liệu thật từ server
setReplies((prev) =>
prev.map((r) => (r.id === tempReply.id ? saved.data : r))
);
} catch (err) {
// 6. Rollback nếu thất bại
setReplies((prev) => prev.filter((r) => r.id !== tempReply.id));
toast.error('Gửi reply thất bại, vui lòng thử lại.');
}
};
Kết quả: reply hiển thị ngay khi nhấn Gửi, không reload, không mất scroll. Sau khoảng 100-300ms server confirm và dữ liệu được thay bằng bản chính thức từ DB.
Scroll xuống reply mới
bottomRef là một div rỗng đặt ở cuối danh sách:
<div className="reply-list">
{replies.map((reply) => (
<ReplyItem key={reply.id} reply={reply} />
))}
<div ref={bottomRef} />
</div>
Gọi scrollIntoView ngay sau khi setReplies — React batches updates nên scroll chạy sau khi DOM đã được cập nhật với reply mới.
Xử lý Socket.IO: tránh duplicate reply
Khi dùng kết hợp với Socket.IO realtime (server push reply về tất cả client trong room), cần xử lý deduplication — tránh reply xuất hiện hai lần: một lần từ optimistic update, một lần từ Socket.IO event.
useEffect(() => {
socket.on('new_reply', (reply) => {
setReplies((prev) => {
// Nếu reply này đã có trong list (do optimistic update), bỏ qua
const exists = prev.find((r) => r.id === reply.id);
if (exists) return prev;
return [...prev, reply];
});
});
return () => socket.off('new_reply');
}, [socket]);
Sau khi optimistic update thay temp-xxx bằng saved.data (reply có id thật từ DB), khi Socket.IO push về cùng reply đó, prev.find sẽ tìm thấy và không append thêm. Còn khi người khác gửi reply, họ không có optimistic update — Socket.IO sẽ append bình thường.
Style reply đang pending
Trong khi chờ server confirm, dùng flag is_pending để hiển thị khác đi:
const ReplyItem = ({ reply }) => (
<div className={`reply ${reply.is_pending ? 'opacity-60' : ''}`}>
<span className="author">{reply.author}</span>
<p>{reply.content}</p>
{reply.is_pending && (
<span className="text-xs text-gray-400">Đang gửi...</span>
)}
</div>
);
Người dùng thấy ngay reply của mình, kèm indicator nhỏ “Đang gửi…” — rõ ràng về trạng thái mà không block tương tác.
Kết quả sau khi áp dụng
Trước: Nhấn Gửi → trang reload → scroll về đầu → phải tìm lại vị trí → thấy reply mới. Với mạng chậm: delay 1-2 giây nhìn như treo.
Sau: Nhấn Gửi → reply hiện ngay → scroll mượt xuống cuối. Server xác nhận sau 100-300ms trong nền, người dùng không nhận ra. Nếu lỗi: reply tạm biến mất, toast thông báo.
Optimistic update không phải chỉ là kỹ thuật nâng cao — nó là cách React apps nên hoạt động theo mặc định với các action write-heavy như chat, comment, reply. Người dùng nhận phản hồi tức thì, ứng dụng cảm giác nhanh hơn hẳn dù backend speed không đổi. Kết hợp với Socket.IO dedup, pattern này đủ để xử lý cả trường hợp nhiều người cùng reply một lúc mà không bị ghost message hay duplicate.