Cách tôi làm:
- Kích hoạt load bằng
IntersectionObserverthay vì lắng nghescroll. Đặt một phần tử "sentinel" ở cuối danh sách; khi nó lọt vào viewport, observer callback gọi load trang tiếp theo. Cách này rẻ hơn hẳn xử lý eventscroll(vốn bắn liên tục, dễ giật). - Chống load trùng: giữ cờ
isLoadingvà con trỏ trang (page/cursor). Nếu đang fetch hoặc đã hết dữ liệu (hasMore === false) thì bỏ qua trigger. Dùng cursor-based tốt hơn offset khi dữ liệu thay đổi liên tục để tránh lặp/nhảy item. - Danh sách rất dài → virtualization (
@tanstack/react-virtual,react-window): chỉ render các dòng đang hiển thị, tránh giữ hàng nghìn DOM node làm tụt hiệu năng và tốn bộ nhớ. - Giữ scroll position khi thêm dữ liệu ở đầu (ví dụ chat load tin nhắn cũ): đo
scrollHeighttrước và sau khi chèn rồi bù lạiscrollTop.
jsx
const sentinelRef = useRef(null)
useEffect(() => {
const io = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && hasMore && !isLoading) loadMore()
}, { rootMargin: '200px' })
if (sentinelRef.current) io.observe(sentinelRef.current)
return () => io.disconnect()
}, [hasMore, isLoading])
// <div ref={sentinelRef} /> at the end of the listKhi nào chọn phân trang thường thay vì infinite scroll: trang cần được đánh dấu/chia sẻ URL, cần footer, hoặc người dùng muốn nhảy tới trang cụ thể. Infinite scroll hợp với feed khám phá.
Nếu dự án đã dùng React Query thì useInfiniteQuery lo sẵn cache và hasNextPage, chỉ còn tự viết phần observer.