import { useCallback, useEffect, useState } from "react";
interface Options {
rootMargin?: string;
threshold?: number;
}
export function useIntersection(options: Options = {}) {
const { rootMargin = "0px", threshold = 0 } = options;
const [node, setNode] = useState<Element | null>(null);
const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null);
const ref = useCallback((el: Element | null) => setNode(el), []);
useEffect(() => {
if (!node || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver(
([observed]) => setEntry(observed),
{ rootMargin, threshold }
);
observer.observe(node);
return () => observer.disconnect();
}, [node, rootMargin, threshold]);
return { ref, entry } as const;
}
import { useCallback, useEffect, useRef, useState } from "react";
import { useIntersection } from "./useIntersection";
export interface Page<T> {
items: T[];
hasMore: boolean;
}
type Fetcher<T> = (page: number) => Promise<Page<T>>;
export function useInfiniteScroll<T>(fetchPage: Fetcher<T>) {
const [items, setItems] = useState<T[]>([]);
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const loadingRef = useRef(false);
const { ref: sentinelRef, entry } = useIntersection({ rootMargin: "200px" });
const loadMore = useCallback(async () => {
if (loadingRef.current || !hasMore) return;
loadingRef.current = true;
setLoading(true);
setError(null);
try {
const result = await fetchPage(page);
setItems((prev) => [...prev, ...result.items]);
setHasMore(result.hasMore);
setPage((prev) => prev + 1);
} catch (err) {
setError(err as Error);
} finally {
loadingRef.current = false;
setLoading(false);
}
}, [fetchPage, page, hasMore]);
useEffect(() => {
if (entry?.isIntersecting) {
void loadMore();
}
}, [entry?.isIntersecting, loadMore]);
return { items, hasMore, loading, error, sentinelRef, loadMore } as const;
}
import { useCallback } from "react";
import { useInfiniteScroll, Page } from "./useInfiniteScroll";
interface Post {
id: string;
title: string;
}
const PAGE_SIZE = 20;
async function fetchPosts(page: number): Promise<Page<Post>> {
const res = await fetch(`/api/posts?offset=${page * PAGE_SIZE}&limit=${PAGE_SIZE}`);
if (!res.ok) throw new Error(`Failed to load posts: ${res.status}`);
const data: Post[] = await res.json();
return { items: data, hasMore: data.length === PAGE_SIZE };
}
export function FeedList() {
const fetchPage = useCallback(fetchPosts, []);
const { items, loading, hasMore, error, sentinelRef } = useInfiniteScroll(fetchPage);
return (
<div className="feed">
<ul>
{items.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
{error && <p className="feed__error">{error.message}</p>}
{loading && <p className="feed__status">Loading…</p>}
{!hasMore && !loading && <p className="feed__status">You’re all caught up.</p>}
{hasMore && <div ref={sentinelRef} className="feed__sentinel" aria-hidden />}
</div>
);
}
Infinite scroll is a common way to page through large lists without a visible pagination control: the app loads the next batch as the user nears the bottom. The naive implementation attaches a scroll listener and measures element offsets on every event, which fires dozens of times per second and forces layout reads. The IntersectionObserver API avoids that entirely by letting the browser notify the app asynchronously when a sentinel element enters the viewport, which is both cheaper and simpler to reason about.
In useIntersection hook, the observer is wrapped in a minimal primitive. It stores the current IntersectionObserverEntry in state and returns a ref callback plus the latest entry. Using a callback ref rather than a useRef object means the effect re-runs whenever the observed node actually changes, so the hook stays correct even if the sentinel unmounts and remounts. The observer is created inside useEffect, keyed on the node and rootMargin, and torn down on cleanup to prevent leaks.
useInfiniteScroll hook composes that primitive into a full paging engine. It is generic over the item type T, keeping a page counter, an accumulated items array, and hasMore/loading flags. When the returned entry reports isIntersecting and no request is already in flight, loadMore calls the injected fetchPage function and appends results. Guarding on loadingRef prevents the classic double-fetch where a fast scroll triggers two concurrent loads for the same page. The rootMargin of 200px starts fetching before the sentinel is fully visible, hiding latency.
The consumer in FeedList component shows why this separation matters: the component knows nothing about observers. It supplies a fetchPage callback, renders items, and drops the sentinelRef on an empty div after the list. When that div scrolls into range, the next page arrives automatically.
A few trade-offs are worth noting. fetchPage should be stable (memoized) or the observer effect churns; here it is wrapped in useCallback. Errors from fetchPage are caught so a failed request does not permanently wedge the loading flag. For servers without cursor support this offset-based approach can duplicate items if the underlying data shifts, in which case a cursor should replace page.
Related snips
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
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.alert = false
Bullet.bullet_logger = true
Bullet.console = true
N+1 query detection with Bullet gem
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
json.array! @posts do |post|
json.cache! ['v1', post], expires_in: 1.hour do
json.id post.id
json.title post.title
json.excerpt post.excerpt
json.published_at post.published_at
Fragment caching for expensive JSON serialization
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
Share this code
Here's the card — post it anywhere.