import { useCallback, useEffect, useRef, useState } from "react";
export function useIntersectionObserver(options?: IntersectionObserverInit) {
const [isIntersecting, setIsIntersecting] = useState(false);
const nodeRef = useRef<Element | null>(null);
const optionsKey = JSON.stringify(options ?? {});
const ref = useCallback((node: Element | null) => {
nodeRef.current = node;
}, []);
useEffect(() => {
const node = nodeRef.current;
if (!node) return;
const observer = new IntersectionObserver(([entry]) => {
setIsIntersecting(entry.isIntersecting);
}, options);
observer.observe(node);
return () => observer.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [optionsKey, nodeRef.current]);
return { ref, isIntersecting };
}
import { useCallback, useRef, useState } from "react";
export interface Page<T> {
items: T[];
nextCursor: string | null;
}
export type FetchPage<T> = (cursor: string | null) => Promise<Page<T>>;
export function useInfiniteFeed<T>(fetchPage: FetchPage<T>) {
const [items, setItems] = useState<T[]>([]);
const [error, setError] = useState<Error | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const cursorRef = useRef<string | null>(null);
const loadingRef = useRef(false);
const loadMore = useCallback(async () => {
if (loadingRef.current || !hasMore) return;
loadingRef.current = true;
setIsLoading(true);
setError(null);
try {
const page = await fetchPage(cursorRef.current);
setItems((prev) => [...prev, ...page.items]);
cursorRef.current = page.nextCursor;
setHasMore(page.nextCursor !== null);
} catch (err) {
setError(err instanceof Error ? err : new Error("Failed to load feed"));
} finally {
loadingRef.current = false;
setIsLoading(false);
}
}, [fetchPage, hasMore]);
return { items, error, isLoading, hasMore, loadMore };
}
import { useEffect } from "react";
import { useIntersectionObserver } from "./useIntersectionObserver";
import { FetchPage, useInfiniteFeed } from "./useInfiniteFeed";
interface FeedProps<T> {
fetchPage: FetchPage<T>;
renderItem: (item: T, index: number) => React.ReactNode;
}
export function Feed<T>({ fetchPage, renderItem }: FeedProps<T>) {
const { items, error, isLoading, hasMore, loadMore } = useInfiniteFeed(fetchPage);
const { ref, isIntersecting } = useIntersectionObserver({ rootMargin: "400px" });
useEffect(() => {
if (isIntersecting) loadMore();
}, [isIntersecting, loadMore]);
return (
<div className="feed">
<ul className="feed__list">
{items.map((item, i) => (
<li key={i} className="feed__item">
{renderItem(item, i)}
</li>
))}
</ul>
{error && (
<div className="feed__error">
<span>{error.message}</span>
<button onClick={() => loadMore()}>Retry</button>
</div>
)}
{isLoading && <div className="feed__spinner">Loading…</div>}
{hasMore ? (
<div ref={ref} className="feed__sentinel" aria-hidden="true" />
) : (
<div className="feed__end">You're all caught up.</div>
)}
</div>
);
}
An infinite-scroll feed loads more items automatically as a sentinel element scrolls into view, avoiding the extra click of a "Load more" button while keeping only one page of work in flight at a time. This snippet splits that behavior into three collaborating files: a low-level useIntersectionObserver hook that reports when a ref becomes visible, a useInfiniteFeed hook that owns cursor-based pagination state, and a Feed component that wires them together.
In useIntersectionObserver, the hook returns a ref callback and an isIntersecting boolean. Using a ref callback rather than a useRef object is deliberate: it fires whenever the DOM node mounts or unmounts, so the observer is attached and torn down at exactly the right moments even when the sentinel is conditionally rendered. The observer is recreated inside a useEffect keyed on the serialized options, and the cleanup disconnect() prevents leaks and stale callbacks. The rootMargin option lets the trigger fire before the sentinel is fully visible, prefetching the next page early for smoother scrolling.
In useInfiniteFeed, pagination is modeled around a cursor instead of a page number. Cursor pagination is more robust than offset pagination because inserts and deletes near the top of the feed don't shift items or cause duplicates. The loadMore function is wrapped in useCallback and guards against concurrent calls with a loadingRef, so a burst of intersection events can't fire overlapping requests. Responses are appended to items, and nextCursor is stored; when the server returns a null cursor, hasMore flips to false and further loads short-circuit. Errors are captured in state rather than thrown, letting the UI offer a retry without unmounting the list.
In Feed, the two hooks meet: the observer's isIntersecting value is watched by an effect that calls loadMore, and the sentinel div receives the observer's ref. Because loadMore is stable and self-guarding, the effect can depend on it safely without re-triggering loops. The generic fetchPage prop keeps the feed reusable across different item types. Together these pieces form a small, testable pattern: observation, pagination, and rendering are each isolated and independently replaceable.
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
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
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.