Cách sửa lỗi "chớp" giao diện trong React với useLayoutEffect

Trong hành trình xây dựng ứng dụng React, chúng ta luôn khao khát một giao diện mượt mà, phản hồi tức thì. Nhưng có lẽ ai cũng từng gặp phải trường hợp khó chịu này: một thành phần giao diện đột ngột "nhảy" hoặc "chớp" (flicker) ngay trước mắt người dùng. Nó xuất hiện trong tích tắc ở trạng thái không mong muốn, rồi mới vội vàng điều chỉnh lại cho đúng.
Đây không chỉ là một lỗi thẩm mỹ. Nó phá vỡ sự liền mạch của trải nghiệm người dùng và tạo cảm giác ứng dụng của chúng ta thiếu chỉn chu. Hôm nay, chúng ta sẽ mổ xẻ một kịch bản phổ biến gây ra hiện tượng này và khám phá một "vũ khí bí mật" trong kho tàng hook của React để giải quyết nó triệt để.
💡 Mẹo hay: Cách học tốt nhất là được học từ những chuyên gia giỏi. Nếu bạn muốn nâng cao kỹ năng React và tối ưu hiệu năng, hãy tham khảo khoá học React Nâng Cao - Chuyên sâu tối ưu hiệu năng của chị Trương Hoài - một kỹ sư phần mềm với nhiều năm kinh nghiệm làm việc tại Singapore.
Xem chi tiết khoá họcVấn đề: Responsive Navbar - Thanh điều hướng tự căn chỉnh
Hãy tưởng tượng bạn đang xây dựng một thanh điều hướng có khả năng hiển thị số lượng mục menu (menu items) tùy thuộc vào chiều rộng của nó. Nếu không đủ không gian, các mục còn lại sẽ được gom vào một menu "Thêm..." (More).
Để biết chính xác bao nhiêu mục có thể vừa vặn, chúng ta không thể đoán mò. Cách duy nhất là:
- Render tất cả các mục ra DOM thật.
- Dùng JavaScript để đo chiều rộng của container và từng mục.
- Tính toán xem có thể hiển thị bao nhiêu mục.
- Render lại component chỉ với số lượng mục đã tính toán.
Một cách tiếp cận tự nhiên và phổ biến là sử dụng useEffect:
import React, { useState, useEffect, useRef } from 'react';
const ResponsiveNav = ({ items }) => {
const [visibleItemCount, setVisibleItemCount] = useState(items.length);
const containerRef = useRef(null);
useEffect(() => {
const containerWidth = containerRef.current.offsetWidth;
let totalWidth = 0;
let newVisibleCount = 0;
for (const child of containerRef.current.children) {
if (totalWidth + child.offsetWidth <= containerWidth) {
totalWidth += child.offsetWidth;
newVisibleCount++;
} else {
break;
}
}
// Cập nhật lại state để render đúng số lượng item
if (newVisibleCount < visibleItemCount) {
setVisibleItemCount(newVisibleCount);
}
}, [items, visibleItemCount]);
return (
<nav ref={containerRef} className="responsive-nav">
{/* Ban đầu, chúng ta render tất cả các item để có thể đo đạc */}
{items.slice(0, visibleItemCount).map(item => (
<a key={item.id} href={item.href}>{item.name}</a>
))}
{visibleItemCount < items.length && <button>More...</button>}
</nav>
);
};
Đoạn code trên về mặt logic là hoàn toàn đúng. Nhưng nó ẩn chứa một vấn đề nghiêm trọng về trải nghiệm người dùng. Khi component render lần đầu, nó sẽ hiển thị tất cả các mục menu. Chỉ sau đó, useEffect mới được kích hoạt, thực hiện phép đo và gọi setVisibleItemCount. Lệnh gọi này kích hoạt một lần re-render nữa.
Mắt của người dùng sẽ chứng kiến toàn bộ quá trình: một thanh điều hướng đầy đủ xuất hiện trong chốc lát, rồi đột ngột bị co lại. Đó là nguyên nhân gây ra vấn đề "chớp giật" khó chịu.
Tại sao lại "chớp"? Phân biệt giữa React "Render" và Browser "Paint"
Gốc rễ của vấn đề nằm ở việc chúng ta thường gộp hai quá trình hoàn toàn riêng biệt làm một. Để hiểu rõ, hãy phân biệt chúng:
- React "Render": Đây là quá trình React chạy các hàm component của bạn, so sánh và tạo ra một "kế hoạch chi tiết" (Virtual DOM) về giao diện người dùng. Toàn bộ quá trình này diễn ra trong bộ nhớ của JavaScript.
- Browser "Paint" (Vẽ): Sau khi React đã áp dụng "kế hoạch" đó để cập nhật DOM thật, trình duyệt sẽ đọc những thay đổi và "vẽ" các pixel lên màn hình. Đây là khoảnh khắc người dùng thực sự nhìn thấy sự thay đổi.
Bí mật nằm ở thời điểm useEffect được thực thi. useEffect được thiết kế để chạy bất đồng bộ, sau khi trình duyệt đã "vẽ" xong. Điều này giúp tối ưu hiệu năng, đảm bảo các tác vụ phụ không làm chậm quá trình hiển thị ban đầu.
Luồng thực thi của đoạn code trên như sau:
- Bước 1: React thực hiện Render Lần 1, tạo ra Virtual DOM với tất cả các mục menu.
- Bước 2: React cập nhật DOM thật.
- Bước 3: Trình duyệt thực hiện Paint Lần 1. Người dùng thấy một thanh menu dài ngoằng.
- Bước 4: Quá trình paint hoàn tất, useEffect được đưa vào hàng đợi và thực thi.
- Bước 5: Logic đo đạc chạy, setVisibleItemCount được gọi.
- Bước 6: React thực hiện Render Lần 2 với số lượng mục menu chính xác.
- Bước 7: React cập nhật DOM thật lần nữa.
- Bước 8: Trình duyệt thực hiện Paint Lần 2. Người dùng thấy thanh menu co lại đúng kích thước.
Cú "chớp" chính là sự khác biệt thị giác giữa Paint Lần 1 và Paint Lần 2.
Vậy làm thế nào để chúng ta có thể thực hiện phép đo và cập nhật trước khi trình duyệt có cơ hội "vẽ" trạng thái sai lệch lên màn hình?
Giải pháp: useLayoutEffect - Can thiệp trước khi màn hình kịp vẽ
Đây chính là sân khấu của useLayoutEffect. Hook này có cú pháp y hệt useEffect, nhưng khác biệt cốt lõi nằm ở thời điểm thực thi:
useLayoutEffect chạy đồng bộ ngay sau khi React đã ghi vào DOM, nhưng trước khi trình duyệt thực hiện paint.
Hãy thay thế useEffect bằng useLayoutEffect trong ví dụ của chúng ta:
import React, { useState, useLayoutEffect, useRef } from 'react';
// ... component vẫn như cũ
const ResponsiveNav = ({ items }) => {
// ... state và ref vẫn như cũ
// Chỉ thay đổi ở đây
useLayoutEffect(() => {
// ... logic đo đạc y hệt
}, [items]);
// ... return JSX y hệt
};
Chỉ với một thay đổi nhỏ, luồng thực thi giờ đây đã trở nên hoàn hảo:
- Bước 1: React thực hiện Render Lần 1 (tất cả các mục).
- Bước 2: React cập nhật DOM thật.
- Bước 3: useLayoutEffect chạy đồng bộ ngay lập tức. Nó chặn trình duyệt paint. Logic đo đạc được thực thi.
- Bước 4: setVisibleItemCount được gọi. Vì đang trong một luồng đồng bộ, React sẽ ngay lập tức bắt đầu Render Lần 2.
- Bước 5: React cập nhật lại DOM thật với kết quả cuối cùng.
- Bước 6: Trình duyệt thực hiện Paint Lần 1. Trình duyệt giờ mới được phép "vẽ", và nó vẽ thẳng kết quả cuối cùng (thanh menu có kích thước chính xác).
Người dùng sẽ không bao giờ nhìn thấy trạng thái trung gian. Cú "chớp" đã hoàn toàn biến mất.
Quyền năng đi kèm với trách nhiệm
Nếu useLayoutEffect kỳ diệu như vậy, tại sao chúng ta không dùng nó thay cho useEffect mọi lúc?
Bởi vì nó chặn luồng hiển thị (blocking). Vì nó chạy đồng bộ, nếu công việc bạn thực hiện bên trong useLayoutEffect quá nặng nề (tính toán phức tạp, thao tác DOM tốn thời gian), nó sẽ làm trì hoãn quá trình paint. Người dùng sẽ cảm thấy ứng dụng bị "khựng" lại.
Hãy ghi nhớ quy tắc vàng:
- Luôn ưu tiên useEffect: Đây là lựa chọn mặc định cho 99% các trường hợp. Hầu hết các tác vụ phụ như fetch dữ liệu, thiết lập subscriptions đều không nên chặn quá trình paint.
- Chỉ dùng useLayoutEffect khi: Bạn cần đọc thông tin layout từ DOM (kích thước, vị trí) và cập nhật lại state/DOM một cách đồng bộ để tránh các lỗi thị giác mà người dùng có thể nhận thấy.
Kết luận
Hiểu rõ sự khác biệt giữa useEffect và useLayoutEffect không chỉ đơn thuần là biết thêm một API. Nó là sự thấu hiểu về mối quan hệ cộng sinh giữa React và trình duyệt. Lỗi "chớp" giao diện không phải là một bug của React, mà là hệ quả tự nhiên của một kiến trúc được thiết kế để ưu tiên hiệu năng. Bằng cách sử dụng đúng công cụ vào đúng thời điểm, chúng ta có thể vừa đảm bảo hiệu năng, vừa mang lại một trải nghiệm người dùng hoàn hảo.
Lần tới khi bạn thấy một thành phần giao diện "nhảy múa" không theo ý muốn, hãy tự hỏi: "Mình có đang đo đạc DOM để thay đổi chính nó không?". Nếu câu trả lời là có, useLayoutEffect chính là người bạn đồng hành đáng tin cậy của bạn.