Bạn đã bao giờ xây dựng một tooltip bị nhảy từ góc trên bên trái sang vị trí đúng của nó chưa? Hoặc một modal bị nhấp nháy ở kích thước sai trước khi ổn định vào vị trí? Sự cố chớp nhoáng đó chính là hiện tượng nhấp nháy bố cục (layout flicker). Nó xảy ra khi React đọc DOM, tính toán một sự điều chỉnh và cập nhật state, nhưng trình duyệt đã bắt đầu đẩy các pixel lên màn hình. Cách giải quyết thông thường là đổi useEffect sang useLayoutEffect. Việc thay đổi này có hiệu quả, nhưng chỉ khi bạn hiểu chính xác thời điểm mỗi hook được kích hoạt bên trong quy trình của trình duyệt.

Quy trình của Trình duyệt: Render, Commit, Paint

React cập nhật một component qua ba giai đoạn riêng biệt. Trong giai đoạn render, React xây dựng—hoặc xây dựng lại—Virtual DOM và tính toán sự khác biệt (diff). Chưa có thay đổi pixel thực tế nào; đây thuần túy là các tính toán diễn ra trong bộ nhớ. Tiếp theo là giai đoạn commit, nơi React áp dụng những thay đổi đó vào các node DOM thực. Các style được cập nhật, các node được chèn vào hoặc xóa đi, và văn bản thay đổi.

Sau đó, trình duyệt sẽ tiếp quản. Trong giai đoạn paint, công cụ render của trình duyệt tính toán hình học bố cục (layout geometry) và vẽ các pixel lên màn hình. Trình tự này là cố định. Trình duyệt phải hoàn tất layout trước khi có thể paint, và phải hoàn tất việc paint trước khi người dùng thấy bất kỳ điều gì mới. Khoảng cách giữa commit và paint được tính bằng mili giây, nhưng nó có thật, và đó chính là cửa sổ nơi useEffectuseLayoutEffect khác biệt nhau.

Tại sao useEffect gây ra hiện tượng nhấp nháy

useEffect chạy bất đồng bộ, được lên lịch để kích hoạt sau khi trình duyệt đã vẽ xong màn hình. DOM đã được cập nhật, các pixel đã được vẽ, và sau đó React mới quay lại để chạy effect của bạn.

Hãy tưởng tượng bạn render một menu thả xuống (dropdown) dưới một nút bấm. Bên trong useEffect, bạn gọi buttonRef.current.getBoundingClientRect(), tính toán tọa độ top và left chính xác, rồi lưu chúng vào state. Vì useEffect chạy sau khi paint, trình duyệt đã vẽ dropdown ở vị trí mặc định của nó, có thể là top: 0, left: 0. Chỉ sau khi quá trình paint đó kết thúc, effect của bạn mới cập nhật state. React commit các tọa độ đã được điều chỉnh, và trình duyệt lại paint lần nữa. Người dùng thấy hai khung hình: vị trí sai, sau đó mới đến vị trí đúng. Sự nhảy hình đó chính là hiện tượng nhấp nháy mà mọi người đều cố gắng tránh.

Đối với việc lấy dữ liệu (data fetching), gọi API, theo dõi phân tích (analytics tracking), hoặc thiết lập các trình lắng nghe sự kiện (event listeners), sự chậm trễ này không quan trọng. Người dùng không quan tâm liệu một tín hiệu phân tích có được gửi đi sau khi paint vài mili giây hay không. Thực tế, việc đẩy các tác vụ không liên quan đến hiển thị cho đến sau khi paint giúp quá trình render ban đầu phản hồi nhanh hơn. Nhưng đối với các điều chỉnh phụ thuộc vào bố cục, useEffect đơn giản là quá muộn.

Cách useLayoutEffect chặn quá trình Paint

useLayoutEffect chạy đồng bộ, ngay sau khi React thay đổi DOM nhưng trước khi trình duyệt có cơ hội tính toán layout hoặc vẽ các pixel. Nó chặn hoàn toàn quy trình paint.

Nếu bạn thực hiện cùng một phép đo dropdown bên trong useLayoutEffect, trình tự sẽ thay đổi. React commit bản cập nhật DOM ban đầu, chạy layout effect của bạn, và việc cập nhật state của bạn sẽ kích hoạt một lần re-render đồng bộ. React commit các tọa độ đã được điều chỉnh, và chỉ sau đó trình duyệt mới thực hiện paint. Người dùng chỉ thấy một khung hình duy nhất, và nó đã chính xác ngay từ đầu.

Hành vi chặn này vừa là tính năng vừa là rủi ro. Vì useLayoutEffect ngăn trình duyệt paint cho đến khi nó hoàn tất, bất kỳ tính toán nặng nề nào bên trong nó cũng sẽ làm đóng băng giao diện người dùng (UI). Thậm chí chỉ vài chục mili giây bị chặn paint cũng khiến người dùng cảm thấy bị giật (jank). Đây là lý do tại sao tài liệu của React khuyên bạn nên bắt đầu với useEffect và chỉ chuyển sang useLayoutEffect khi bạn thực sự quan sát thấy hiện tượng nhấp nháy không thể chấp nhận được.

Khi nào nên sử dụng mỗi Hook

Hầu hết logic của bạn nên nằm trong useEffect. Hãy dùng nó để:

  • Lấy dữ liệu từ một API
  • Thiết lập các subscription hoặc trình lắng nghe sự kiện
  • Gửi các sự kiện phân tích (analytics events)
  • Bất kỳ side effect nào không đọc hoặc thay đổi layout ngay lập tức

Hãy dành riêng useLayoutEffect cho các thao tác cần đọc DOM và ghi lại kết quả trước khi người dùng nhìn thấy khung hình:

  • Đo kích thước phần tử, chẳng hạn như chiều rộng, chiều cao hoặc vị trí cuộn (scroll position)
  • Tính toán tọa độ cho tooltips, popovers hoặc context menus
  • Ngăn chặn sự thay đổi bố cục hiển thị (layout shifts) khi vị trí trực quan phụ thuộc vào hình học đã render

Nếu bạn không chắc chắn nên chọn cái nào, hãy mặc định dùng useEffect. Chỉ chuyển sang useLayoutEffect khi bạn nhận thấy sự mất ổn định về mặt hiển thị. Chỉ riêng quy tắc này sẽ giúp đại đa số các ứng dụng React chạy mượt mà.

Lưu ý về Server-Side Rendering

Nếu bạn sử dụng Next.js, Remix, hoặc bất kỳ framework nào thực hiện render React trên server, bạn sẽ gặp cảnh báo với useLayoutEffect. Vì server không có DOM, hook này không có gì để đo lường. React cảnh báo rằng nó mong đợi một môi trường trình duyệt nhưng không tìm thấy. Trong quá trình hydration, sự không đồng nhất này cũng có thể gây ra các lỗi tiềm ẩn vì markup được render trên server và lần render đầu tiên dự kiến của client có thể khác nhau.

Cách khắc phục tiêu chuẩn là sử dụng một isomorphic hook để chọn effect phù hợp dựa trên môi trường:

const useIsomorphicLayoutEffect =
  typeof window !== 'undefined' ? useLayoutEffect : useEffect;

Hãy sử dụng wrapper này trong bất kỳ component nào cần đo lường các DOM node nhưng có khả năng thực thi trong quá trình server rendering. Nó sẽ dập tắt cảnh báo và giữ cho đầu ra của server luôn nhất quán.

Hiệu năng và Các thực hành tốt nhất

useLayoutEffect chặn quá trình painting, hãy giữ phần thân của hook càng nhẹ càng tốt. Hãy đọc giá trị layout, tính toán giá trị điều chỉnh và ghi lại nó. Đừng fetch dữ liệu, parse các object lớn, hoặc chạy các thuật toán tốn kém bên trong nó. Mã nguồn nặng nề ở đây sẽ làm tắc nghẽn main thread và khiến giao diện của bạn có cảm giác bị đóng băng.

Khi đo lường các phần tử, hãy sử dụng React refs thay vì document.getElementById. Refs được gắn liền với instance của component, tồn tại qua các lần re-render mà không cần các thủ thuật truy vấn, và hoạt động đáng tin cậy với portals hoặc conditional rendering. Việc tìm kiếm ID toàn cục sẽ phá vỡ tính đóng gói của component và có thể trả về null ngay tại thời điểm bạn cần chúng nhất.

useEffect là lựa chọn mặc định đúng đắn cho hầu hết mọi side effect. Nó cho phép trình duyệt thực hiện painting mà không bị gián đoạn, đồng thời xử lý dữ liệu, sự kiện và đồng bộ hóa bên ngoài một cách gọn gàng. useLayoutEffect là một công cụ chuyên dụng cho một vấn đề cụ thể: đọc layout và ghi lại trước khi painting. Khi nắm vững sự khác biệt về thời điểm (timing) giữa chúng, bạn sẽ ngừng việc phải chạy theo xử lý các hiện tượng nhấp nháy (flickers) và bắt đầu ngăn chặn chúng ngay từ đầu.

Bài học thực tế: Hãy bắt đầu với useEffect cho mọi thứ. Ngay khoảnh khắc bạn thấy một tooltip hoặc modal nhấp nháy sai vị trí trước khi tự điều chỉnh lại, đó chính là tín hiệu. Hãy chuyển sang useLayoutEffect, đo lường DOM, điều chỉnh layout của bạn, và để trình duyệt thực hiện painting một lần duy nhất—một cách chính xác.