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
- Replace the feed's manual Load more button with automatic infinite scroll: when the sentinel element at the bottom of the list approaches the viewport, the next page must load without a click.
- Every trigger must load the page after the one most recently loaded — page 2, then 3, then 4 — until the API responds with `has_more: false`. The feed must never re-request a page it has already appended.
- An activity item must never appear in the list twice. At most one page request may be in flight at any moment: if the sentinel fires again while a request is pending, that trigger must be ignored — not queued, not duplicated.
- Keep the existing behaviours intact: page 1 arrives via `initialItems`/`initialHasMore` from the server render, a loading indicator shows while a page is fetching, a failed request shows the error with a Retry button, the empty state still renders, and the observer is disconnected on unmount.
Files touched
- src/components/ActivityFeed.jsx
--- 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;