export interface Page<T> {
items: T[];
nextCursor: string | null;
}
export interface Post {
id: string;
title: string;
author: string;
}
export async function fetchPage(
cursor: string | null,
signal: AbortSignal
): Promise<Page<Post>> {
const params = new URLSearchParams({ limit: "20" });
if (cursor) params.set("cursor", cursor);
const res = await fetch(`/api/posts?${params.toString()}`, { signal });
if (!res.ok) {
throw new Error(`Failed to load posts: ${res.status}`);
}
return (await res.json()) as Page<Post>;
}
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchPage, Post } from "./api";
export function useInfiniteList() {
const [items, setItems] = useState<Post[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const cursorRef = useRef<string | null>(null);
const loadingRef = useRef(false);
const controllerRef = useRef<AbortController | null>(null);
const loadMore = useCallback(async () => {
if (loadingRef.current || done) return;
loadingRef.current = true;
setLoading(true);
setError(null);
const controller = new AbortController();
controllerRef.current = controller;
try {
const page = await fetchPage(cursorRef.current, controller.signal);
setItems((prev) => [...prev, ...page.items]);
cursorRef.current = page.nextCursor;
if (page.nextCursor === null) setDone(true);
} catch (err) {
if ((err as Error).name === "AbortError") return;
setError((err as Error).message);
} finally {
loadingRef.current = false;
setLoading(false);
}
}, [done]);
useEffect(() => {
return () => controllerRef.current?.abort();
}, []);
return { items, loading, error, done, loadMore };
}
import { useEffect, useRef } from "react";
import { useInfiniteList } from "./useInfiniteList";
export function InfiniteList() {
const { items, loading, error, done, loadMore } = useInfiniteList();
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
loadMore();
}, [loadMore]);
useEffect(() => {
const node = sentinelRef.current;
if (!node || done) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !loading) {
loadMore();
}
},
{ rootMargin: "200px" }
);
observer.observe(node);
return () => observer.disconnect();
}, [loadMore, loading, done]);
return (
<div className="feed">
<ul>
{items.map((post) => (
<li key={post.id}>
<strong>{post.title}</strong> — {post.author}
</li>
))}
</ul>
{error && <p className="feed__error">{error}</p>}
{loading && <p className="feed__status">Loading…</p>}
{done && <p className="feed__status">No more posts</p>}
<div ref={sentinelRef} aria-hidden="true" />
</div>
);
}
This snippet builds an infinite-scroll list the way it tends to look in a real React codebase: a small typed API helper, a reusable data hook that tracks cursor pagination, and a component that wires a sentinel element to an IntersectionObserver. The pieces are split so the fetching logic stays testable and the component stays declarative.
In api.ts, fetchPage wraps a cursor-based endpoint. Cursor pagination is preferred over offset pagination for feeds because it is stable under inserts and deletes — a nextCursor opaque token always points at the same logical position, whereas ?page=2 shifts when rows change. The function accepts an AbortSignal so in-flight requests can be cancelled, and it returns a typed Page<T> carrying the items plus the next cursor.
In useInfiniteList hook, the state machine is kept deliberately small: items, a cursor, a loading flag, and a done flag once the server stops returning a cursor. loadMore is guarded by a loadingRef so overlapping calls — which are easy to trigger with a fast-scrolling observer — collapse into one request. Each call creates an AbortController and the cleanup effect aborts any pending fetch on unmount, avoiding the classic "set state on an unmounted component" warning and wasted bandwidth. The hook only appends when the request succeeds and ignores AbortError, so cancellation is not treated as a failure.
In InfiniteList component, a sentinelRef marks an empty div at the bottom of the list. An IntersectionObserver watches that node with a rootMargin so loading starts slightly before the sentinel is visible, giving the network time to respond before the user hits the bottom. The observer is recreated inside useEffect and disconnected on cleanup, and it calls loadMore only while not loading and not done. Rendering keys on a stable id rather than array index so React reconciliation stays correct as items append.
The main trade-offs: IntersectionObserver needs a real sentinel in the DOM, and very short lists that never scroll will not trigger a second fetch — a rootMargin or an initial "fill the viewport" pass addresses that. This pattern is the right reach when a feed grows unbounded and offset counts are unreliable or expensive.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
import { Controller } from "@hotwired/stimulus"
import Mousetrap from "mousetrap"
export default class extends Controller {
connect() {
// Global shortcuts
Keyboard shortcuts with Stimulus and Mousetrap
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
Share this code
Here's the card — post it anywhere.