Infinite Scroll Feed
Machine-coding brief for a cursor-paginated feed — IntersectionObserver, race safety, empty/error states, and a11y.
intermediate5 min read
- machine-coding
- react
- performance
- async
- interview
Problem statement
Build an infinite scroll feed: a vertical list of cards that loads the next page when the user approaches the end. Mock or real API with cursor-based pagination. Interviewers care about fetch lifecycle, observer cleanup, duplicate pages, and accessibility — not Instagram-level design.
Requirements
Must have
- Initial page load on mount
- Append next page when sentinel enters viewport (or near-bottom scroll)
- Loading indicator for first page and “load more”
- End-of-feed state (
hasMore === false) - Error state with retry
- No duplicate items when pages overlap or effect re-runs
- Keyboard users can still reach content (list is in tab order; don’t rely on scroll alone for critical actions)
Should have
AbortControlleron unmount / query change- Pull-to-refresh or explicit “Reload”
- Skeleton placeholders for first paint
Nice to have
- Virtualization for 1k+ rows
- Scroll restoration
- Filter/tab that resets cursor and list
Planning (5 minutes out loud)
- Page API —
fetchPage(cursor) → { items, nextCursor } - State —
items,cursor,status,hasMore - Trigger —
IntersectionObserveron a sentineldiv - Guards — in-flight lock so we don’t double-fetch
- MVP — list + sentinel + append; then errors/skeletons
Architecture
FeedApp
├── FeedFilters? // optional, resets feed
├── FeedList
│ ├── FeedCard
│ └── LoadSentinel // observed target
└── hooks/useInfiniteFeed
Data model
type FeedItem = {
id: string;
title: string;
body: string;
imageUrl?: string;
};
type Page = {
items: FeedItem[];
nextCursor: string | null; // null ⇒ no more pages
};
type FeedStatus = "idle" | "loading" | "loadingMore" | "error" | "success";
Component API
type InfiniteFeedProps = {
fetchPage: (cursor: string | null, signal: AbortSignal) => Promise<Page>;
pageSize?: number; // documentation only if server-controlled
};
Implementation sketch
Infinite feed hook
function useInfiniteFeed(fetchPage: InfiniteFeedProps["fetchPage"]) {
const [items, setItems] = useState<FeedItem[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(true);
const [status, setStatus] = useState<FeedStatus>("idle");
const [error, setError] = useState<string | null>(null);
const inFlight = useRef(false);
const seen = useRef(new Set<string>());
const load = useCallback(
async (reset: boolean) => {
if (inFlight.current) return;
if (!reset && !hasMore) return;
inFlight.current = true;
setStatus(reset || items.length === 0 ? "loading" : "loadingMore");
setError(null);
const controller = new AbortController();
try {
const page = await fetchPage(reset ? null : cursor, controller.signal);
setItems((prev) => {
const base = reset ? [] : prev;
if (reset) seen.current.clear();
const next: FeedItem[] = [];
for (const item of page.items) {
if (seen.current.has(item.id)) continue;
seen.current.add(item.id);
next.push(item);
}
return base.concat(next);
});
setCursor(page.nextCursor);
setHasMore(page.nextCursor != null);
setStatus("success");
} catch (e) {
if (e instanceof DOMException && e.name === "AbortError") return;
setError(e instanceof Error ? e.message : "Failed to load");
setStatus("error");
} finally {
inFlight.current = false;
}
},
[cursor, fetchPage, hasMore, items.length]
);
// initial
useEffect(() => {
void load(true);
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount once for MVP
}, []);
return {
items,
status,
error,
hasMore,
loadMore: () => void load(false),
reload: () => void load(true),
};
}
For production, store the abort controller in a ref and abort on unmount/filter change; keep load dependencies tight to avoid stale closures (or use a reducer).
Sentinel with IntersectionObserver
function LoadSentinel({
onVisible,
disabled,
}: {
onVisible: () => void;
disabled: boolean;
}) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (disabled) return;
const node = ref.current;
if (!node) return;
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) onVisible();
},
{ root: null, rootMargin: "200px", threshold: 0 }
);
io.observe(node);
return () => io.disconnect();
}, [onVisible, disabled]);
return <div ref={ref} data-testid="feed-sentinel" aria-hidden="true" />;
}
function FeedApp({ fetchPage }: InfiniteFeedProps) {
const { items, status, error, hasMore, loadMore, reload } =
useInfiniteFeed(fetchPage);
return (
<section aria-label="Feed" aria-busy={status === "loading"}>
{status === "loading" && items.length === 0 && <p>Loading…</p>}
{error && (
<p role="alert">
{error}{" "}
<button type="button" onClick={reload}>
Retry
</button>
</p>
)}
<ul>
{items.map((item) => (
<li key={item.id}>
<article>
<h2>{item.title}</h2>
<p>{item.body}</p>
</article>
</li>
))}
</ul>
{hasMore && (
<LoadSentinel
onVisible={loadMore}
disabled={status === "loadingMore" || status === "loading"}
/>
)}
{status === "loadingMore" && <p>Loading more…</p>}
{!hasMore && items.length > 0 && <p>You’re all caught up.</p>}
{status === "success" && items.length === 0 && <p>No posts yet.</p>}
</section>
);
}
Accessibility notes
- Semantic list / articles; headings per card if content-heavy.
aria-busyon first load;role="alert"for errors.- Infinite scroll fails some users — offer a visible “Load more” button as progressive enhancement (strong interview signal).
- Don’t steal focus when new items append.
- Images:
alt, dimensions to limit CLS (Core Web Vitals).
Performance notes
- Prefer cursor pagination over
page=n(stable under inserts). rootMarginprefetches before the user hits the bottom.- Dedupe by
idwhen the backend is eventually consistent. - Virtualize when asked (windowing discussion).
- Avoid putting heavy work in the observer callback; just trigger fetch.
Interview expectations
| Signal | What good looks like |
|---|---|
| Pagination | Cursor + hasMore, not magic page numbers only |
| Observer | Create + disconnect cleanup |
| Concurrency | In-flight guard; no double page |
| UX | First load, load more, end, error/retry |
| A11y | Load more fallback, alerts, semantics |
| Time | Working list + sentinel in ~30 min |
Extensions they may ask live
- Switch tabs/filters — reset cursor, abort in-flight
- Optimistic remove / like on a card
- Virtualized window (
@tanstack/react-virtualor DIY) - Offline cache of last page
Related
Get the fetch state machine right; virtualization is a follow-up, not the MVP.