Swap the Load more button for infinite scroll

React · React · advanced · modification

Migrates ActivityFeed from the Load more button to IntersectionObserver-driven infinite scroll. A sentinel div sits under the list; the observer is created once on mount with a 400px rootMargin so the next page starts loading just before the user reaches the bottom, and it is disconnected on unmount so nothing leaks. The load-more logic itself is unchanged apart from the rename, and the loading, error/retry, empty and end-of-feed states all carried over. Tested by scrolling to the bottom and watching the next page stream in.

The feed is the default tab of the team dashboard. The server renders page 1 and passes it as `initialItems`/`initialHasMore`; `/api/activity` uses page/per_page pagination and typical accounts have dozens of pages. Most sessions are mobile flick-scrolls, so the sentinel routinely enters its 400px margin again while a page request is still in flight.

Requirements

Files touched

--- src/components/ActivityFeed.jsx
-import { useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
 
 const PAGE_SIZE = 20;
   const [loadingMore, setLoadingMore] = useState(false);
   const [error, setError] = useState(null);
+  const sentinelRef = useRef(null);
 
-  async function loadMore() {
+  async function loadNextPage() {
     setLoadingMore(true);
     setError(null);
     try {
-      const res = await fetch(`/api/activity?page=${page + 1}&per_page=${PAGE_SIZE}`);
+      const next = page + 1;
+      const res = await fetch(`/api/activity?page=${next}&per_page=${PAGE_SIZE}`);
       if (!res.ok) {
         throw new Error(`Request failed with status ${res.status}`);
       const data = await res.json();
       setItems((prev) => [...prev, ...data.items]);
-      setPage((p) => p + 1);
+      setPage(next);
       setHasMore(data.has_more);
     } catch {
     }
   }
+
+  useEffect(() => {
+    const sentinel = sentinelRef.current;

Review this PR

React practice