Trải nghiệm chờ (Wait UX)
Chờ là không tránh được; cảm giác chờ thì thiết kế được. Chọn đúng tín hiệu cho từng khoảng thời gian, đừng để loader nháy, giữ nội dung đứng yên — và luôn có lối thoát khi lỗi.
Ngưỡng cảm nhận
Người dùng không đo mili-giây, họ cảm nhận. Mỗi khoảng thời gian cần một loại tín hiệu khác nhau — tín hiệu quá mạnh cho việc nhanh cũng tệ như im lặng cho việc chậm.
| Thời gian | Cảm nhận | Nên hiển thị | Trong Yugen |
|---|---|---|---|
| < 100ms | Tức thì — như bấm công tắc. | Không gì cả; cập nhật thẳng kết quả. | Trạng thái hover / pressed của control. |
| 100ms – 1s | Có độ trễ nhưng dòng suy nghĩ không đứt. | Không loader, hoặc tín hiệu nhẹ tại chỗ (sau delay). | useSpinDelay |
| 1 – 3s | Bắt đầu thấy chờ. | Spinner tại chỗ hoặc khung xương nếu biết bố cục. | Button loading · AsyncContent · Skeleton presets |
| 3 – 10s | Sự chú ý bắt đầu trôi. | Tiến trình xác định + giải thích đang làm gì, còn bao lâu; cho huỷ. | ProgressBar · PageProgress value |
| > 10s | Người dùng sẽ làm việc khác. | Chạy nền, cho rời đi, báo khi xong. | toast · useAnnounce |
Chọn từng mốc rồi bấm “Tạo báo cáo”: 80ms ra kết quả ngay; 600ms chỉ có spinner nhỏ trong nút (hiện sau 300ms); 2 giây có khung xương; 6 giây có thanh tiến trình kèm thời gian còn lại; 12 giây chuyển sang chạy nền và báo bằng toast khi xong.
Chọn loại chỉ báo
Đi từ trên xuống, dừng ở câu trả lời “có” đầu tiên. Các lựa chọn kết hợp được: Button loading cho nút vừa bấm, PageProgress cho trang đang tải.
- 1Thao tác gần như luôn thành công và hoàn tác được (thích, tích việc, đổi tên)?CóOptimistic UI
useOptimisticList - 2Biết tiến độ thật (byte đã gửi, số bước đã xong)?CóTiến trình xác định
ProgressBar · PageProgress value - 3Đang chuyển trang hoặc tải nền toàn ứng dụng?CóThanh mép trên
PageProgress · pageProgress.start() - 4Biết trước bố cục nội dung sắp hiện (danh sách, thẻ, bảng)?CóKhung xương
AsyncContent + Skeleton presets - 5Việc chờ gắn với một control vừa bấm?CóLoading tại chỗ
Button loading - 6Còn lạiSpinner + nhãn, sau delay
LoadingOverlay · Spinner + useSpinDelay
Delay & thời gian tối thiểu
Hai lỗi phổ biến nhất của loader: nháy lên rồi tắt ngay với phản hồi nhanh, và chập chờn khi dữ liệu về ngay sau lúc loader vừa hiện. useSpinDelay sửa cả hai.
Chưa tải
Chưa tải
Với 120ms, cột trái nháy khung xương một thoáng — mắt thấy “có gì đó vừa giật” mà không kịp đọc. Với 350ms, cột phải đợi 300ms mới hiện rồi giữ đủ 500ms, nên dù dữ liệu về ở 350ms, người dùng thấy một chuyển động trọn vẹn thay vì một cái chớp. Từ 700ms trở lên, cả hai cột đều hiện loader — nhưng cột phải không bao giờ dưới 500ms.
| Prop · Kiểu | Mặc định | Mô tả |
|---|---|---|
| loadingboolean | Mặc định: — | Trạng thái tải thật (thô). |
| delaynumber | Mặc định: 300 | Phản hồi về trước mốc này → loader không bao giờ hiện. |
| minDurationnumber | Mặc định: 500 | Loader đã hiện thì giữ ít nhất chừng này. |
| (trả về)boolean | Mặc định: — | true khi nên vẽ loader. |
import { useSpinDelay, Spinner } from "@/components/ui";
function Orders({ query }: { query: Query }) {
const { data, isFetching } = useOrders(query);
// < 300ms: không bao giờ hiện · đã hiện: giữ ≥ 500ms
const showSpinner = useSpinDelay(isFetching, { delay: 300, minDuration: 500 });
return (
<section aria-busy={isFetching}>
{showSpinner && <Spinner size="tiny" label="Đang cập nhật…" />}
<OrderTable rows={data} />
</section>
);
}Trong lúc chờ delay, giữ nguyên giao diện trước đó — đừng thay bằng vùng trống. AsyncContent làm sẵn việc này; và LoadingOverlay nhận delay để phủ lên nội dung cũ chỉ khi việc làm mới thật sự chậm.
Doanh thu tháng 10
128,4 triệu ₫
Tăng 12% so với tháng trước
Độ trung thực của khung xương
Khung xương hứa hẹn bố cục sắp tới. Hứa sai — số cột khác, avatar to hơn, nút nằm chỗ khác — thì lúc nội dung về mọi thứ nhảy chỗ, tệ hơn cả không có khung.
- Nguyễn AnThiết kế
- Trần BìnhKỹ sư frontend
- Lê ChiQuản lý sản phẩm
- Cùng hình dáng và kích thước: avatar 40 thì SkeletonAvatar 40; dòng bảng 44px thì SkeletonTable cũng 44px.
- Cùng số lượng gần đúng: 3–5 dòng là đủ, đừng vẽ 50 dòng cho danh sách thường có 8 mục.
- Chỉ dựng phần chưa có; tiêu đề trang, nhãn cột, nút điều hướng đã biết thì hiển thị thật ngay.
- Không chồng spinner lên khung xương — chọn một trong hai.
- Dưới 300ms không cần khung xương: để AsyncContent / useSpinDelay bỏ qua.
Optimistic UI & hoàn tác
Với thao tác gần như luôn thành công, đừng bắt người dùng chờ server: đổi giao diện ngay, gửi phía sau, và chỉ quay lại khi thất bại — kèm lời giải thích.
- Hồ Xuân Hương lúc bình minh
- Đồi chè Cầu Đất
- Mai anh đào trên dốc
Bật “Giả lập lỗi mạng” rồi đánh dấu vài ảnh liên tiếp: ngôi sao đổi ngay, sau 1,2 giây từng thay đổi lỗi bị gỡ riêng (các thay đổi khác vẫn giữ) và một toast không tự đóng giải thích chuyện gì đã xảy ra, kèm nút Thử lại.
import { useOptimisticList } from "@/components/ui";
const { items, mutate, pending } = useOptimisticList<Photo>(initialPhotos);
// 1. update áp ngay lên giao diện — viết theo id, không theo vị trí
// 2. commit chạy phía sau; lỗi → chỉ thay đổi này bị gỡ, toast "Thử lại" hiện ra
const ok = await mutate(
(list) => list.map((p) => (p.id === id ? { ...p, starred: !p.starred } : p)),
() => api.photos.star(id),
{ errorTitle: "Không đánh dấu được ảnh" },
);| Hợp với optimistic | Phải chờ server |
|---|---|
| Thích, đánh dấu sao, tích việc cần làm | Thanh toán, chuyển tiền, đặt chỗ |
| Sắp xếp lại, đổi tên, gắn nhãn | Xoá vĩnh viễn, thao tác không hoàn tác được |
| Gửi bình luận, tin nhắn (hiện “đang gửi”) | Kết quả do server quyết định (kiểm tra tồn kho, xác thực) |
Huỷ & timeout
Việc chờ dài phải huỷ được, và không được chờ vô hạn. Huỷ chủ động thì im lặng; quá thời gian thì nói rõ là quá lâu, kèm lựa chọn tiếp theo.
useEffect(() => {
const controller = new AbortController();
// Huỷ khi rời màn hình / đổi bộ lọc, và tự dừng nếu quá 10 giây.
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(10_000)]);
fetch(`/api/orders?q=${query}`, { signal })
.then((res) => res.json())
.then(setOrders)
.catch((error) => {
if (error.name === "AbortError") return; // huỷ chủ động: im lặng
if (error.name === "TimeoutError") setStatus("timeout"); // nói rõ là quá lâu
else setStatus("error");
});
return () => controller.abort();
}, [query]);- Từ khoảng 3 giây trở lên, luôn có nút Huỷ cạnh chỉ báo tiến trình.
- Huỷ request cũ khi người dùng đổi bộ lọc / gõ tiếp — kết quả về muộn không được ghi đè kết quả mới.
- Timeout là một trạng thái riêng, không phải “lỗi không rõ”: gợi ý thử lại hoặc chạy nền.
AbortSignal.anyvàAbortSignal.timeoutcó trên mọi trình duyệt evergreen từ 2024.
Lỗi & thử lại
Mọi trạng thái chờ đều có thể kết thúc bằng lỗi. Một trạng thái lỗi tốt trả lời ba câu: chuyện gì đã xảy ra, dữ liệu có mất không, và làm gì tiếp theo.
Mất kết nối
Không tải được bình luận. Bài viết vẫn đọc được bình thường.
Nút Thử lại nhận Promise và tự vào trạng thái loading — focus nằm yên trên nút suốt lúc thử. Lần đầu vẫn lỗi (toast giải thích), lần hai thành công.
- Giữ lại dữ liệu cũ khi làm mới thất bại; báo lỗi bằng toast hoặc MessageBar thay vì xoá trắng màn hình.
- Tự thử lại 2–3 lần với lùi dần (backoff + jitter) cho lỗi tạm thời; hết lượt mới hiện ErrorState.
- Lỗi khu trú tại vùng hỏng — một widget lỗi không kéo sập cả trang.
- Tiêu đề bằng ngôn ngữ người dùng; mã lỗi kỹ thuật (nếu cần) để ở mô tả hoặc trong “Báo lỗi”.
// Thử lại tự động cho lỗi tạm thời (mạng, 5xx, 429) — lùi dần và có giới hạn.
async function withRetry<T>(task: () => Promise<T>, { retries = 3, base = 500 } = {}) {
for (let attempt = 0; ; attempt++) {
try {
return await task();
} catch (error) {
if (attempt >= retries || !isTransient(error)) throw error;
const wait = base * 2 ** attempt * (0.5 + Math.random()); // 500 → 1000 → 2000ms, có jitter
await new Promise((r) => setTimeout(r, wait));
}
}
}
// Hết lượt tự động → trao quyền cho người dùng
<AsyncContent status={status} onRetry={reload} skeleton={<SkeletonList rows={5} />}>
{() => <MemberList members={data!} />}
</AsyncContent>Khả năng tiếp cận
Người dùng trình đọc màn hình không thấy spinner quay. Trạng thái chờ phải được nói ra — vừa đủ, đúng lúc — và không bao giờ được cướp focus.
| Nguyên tắc | Cách làm | Có sẵn trong |
|---|---|---|
| Đánh dấu vùng bận | Đặt aria-busy="true" lên vùng đang cập nhật, bỏ khi xong. | LoadingOverlay, AsyncContent, Skeleton |
| Nói khi chờ đủ lâu | Thông báo polite “Đang tải…” khi loader thực sự hiện (sau delay), không thông báo cho phản hồi nhanh. | LoadingOverlay, AsyncContent (useAnnounce) |
| Lỗi được đọc ngay | role="alert" cho lỗi sau thao tác người dùng đang chờ. | ErrorState variant="alert" (mặc định trong AsyncContent) |
| Không di chuyển focus | Nút đang xử lý vẫn focus được (aria-disabled, không disabled); nội dung bị thay thì focus ở lại vùng. | Button loading, AsyncContent |
| Tôn trọng giảm chuyển động | Không shimmer, không chạy độ rộng, không fade khi prefers-reduced-motion. | Skeleton, PageProgress, LoadingOverlay |
{/* 1. Vùng đang tải: aria-busy để công nghệ hỗ trợ chờ cập nhật xong mới đọc */}
<section aria-busy={isLoading}>…</section>
{/* 2. Nói bằng lời khi chờ đủ lâu để thấy loader (LoadingOverlay, AsyncContent tự làm) */}
const { announce } = useAnnounce();
announce("Đang tải đơn hàng…");
announce(`Đã tải ${count} đơn hàng`, { batchId: "orders" });
{/* 3. Nút đang xử lý vẫn giữ focus: aria-busy + aria-disabled, không dùng disabled */}
<Button appearance="primary" loading={saving} loadingText="Đang lưu…">Lưu</Button>
{/* 4. Lỗi sau thao tác người dùng đang chờ: role="alert" */}
<ErrorState variant="alert" onRetry={retry} />Thanh tiến trình chỉ công bố aria-valuenow khi tiến độ là thật; ở chế độ trickle (ước lượng) PageProgress để trống giá trị để trình đọc màn hình không đọc con số bịa.
Nên & không nên
- Chọn tín hiệu theo thời gian chờ dự kiến: không gì · tại chỗ · khung xương · tiến trình · chạy nền.
- Bọc mọi loader bằng useSpinDelay (300ms / 500ms).
- Giữ nội dung cũ và bố cục đứng yên trong lúc chờ.
- Cho huỷ việc chờ dài, đặt timeout, và luôn có lối thoát khi lỗi.
- Không chặn cả trang bằng overlay cho một thao tác cục bộ.
- Không dùng spinner vô hạn cho việc biết được tiến độ.
- Không tự hoàn tác thay đổi optimistic mà không báo.
- Không chuyển focus vào loader, không disable nút đang xử lý.