import { useCallback, useEffect, useRef, useState } from 'react';
export function usePaginatedFetch(fetchPage, { pageSize = 20 } = {}) {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [hasMore, setHasMore] = useState(true);
const pageRef = useRef(0);
const loadingRef = useRef(false);
const abortRef = useRef(null);
const loadMore = useCallback(async () => {
if (loadingRef.current || !hasMore) return;
loadingRef.current = true;
setLoading(true);
setError(null);
if (abortRef.current) abortRef.current.abort();
const controller = new AbortController();
abortRef.current = controller;
try {
const nextPage = pageRef.current + 1;
const batch = await fetchPage(nextPage, pageSize, controller.signal);
pageRef.current = nextPage;
setItems((prev) => [...prev, ...batch]);
setHasMore(batch.length === pageSize);
} catch (err) {
if (err.name !== 'AbortError') setError(err);
} finally {
loadingRef.current = false;
setLoading(false);
}
}, [fetchPage, pageSize, hasMore]);
useEffect(() => {
return () => {
if (abortRef.current) abortRef.current.abort();
};
}, []);
return { items, loading, error, hasMore, loadMore };
}
import { useEffect, useRef } from 'react';
export function useOnScreen(ref, onIntersect, { rootMargin = '200px', threshold = 0 } = {}) {
const savedCallback = useRef(onIntersect);
useEffect(() => {
savedCallback.current = onIntersect;
}, [onIntersect]);
useEffect(() => {
const node = ref.current;
if (!node || typeof IntersectionObserver === 'undefined') return undefined;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) savedCallback.current();
});
},
{ rootMargin, threshold }
);
observer.observe(node);
return () => observer.disconnect();
}, [ref, rootMargin, threshold]);
}
import { useCallback, useRef } from 'react';
import { usePaginatedFetch } from './usePaginatedFetch';
import { useOnScreen } from './useOnScreen';
async function fetchPosts(page, pageSize, signal) {
const res = await fetch(`/api/posts?page=${page}&limit=${pageSize}`, { signal });
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
const data = await res.json();
return data.items;
}
export default function FeedList() {
const { items, loading, error, hasMore, loadMore } = usePaginatedFetch(fetchPosts, {
pageSize: 20,
});
const sentinelRef = useRef(null);
const handleIntersect = useCallback(() => {
if (hasMore && !loading) loadMore();
}, [hasMore, loading, loadMore]);
useOnScreen(sentinelRef, handleIntersect, { rootMargin: '400px' });
return (
<div className="feed">
<ul className="feed__list">
{items.map((post) => (
<li key={post.id} className="feed__item">
<h3>{post.title}</h3>
<p>{post.excerpt}</p>
</li>
))}
</ul>
{error && (
<div className="feed__error">
<span>Failed to load.</span>
<button type="button" onClick={loadMore}>Retry</button>
</div>
)}
{loading && <div className="feed__spinner">Loading…</div>}
{!hasMore && !loading && <div className="feed__end">You're all caught up.</div>}
<div ref={sentinelRef} aria-hidden="true" style={{ height: 1 }} />
</div>
);
}
This snippet shows the two halves of a real infinite-scroll list in React: a data hook that owns pagination state and network calls, and a sentinel hook that fires a callback when a DOM element scrolls into view. Keeping these concerns separate means the fetching logic can be tested and reused independently of how loading is triggered.
In usePaginatedFetch hook, pagination is modeled as an accumulating list plus a cursor. Each call to loadMore bumps the internal pageRef and appends the freshly fetched page onto items rather than replacing it, which is what makes the list grow instead of paging. The hook tracks loading, error, and hasMore so consumers can render spinners, retry buttons, and a natural stop condition. An AbortController is created per request and aborted on cleanup or when a new request supersedes the old one, avoiding the classic race where a slow earlier page resolves after a later one and clobbers state. The loadingRef guard is important: without it, several observer callbacks could fire in quick succession and launch duplicate requests for the same page.
In useOnScreen hook, an IntersectionObserver watches a single sentinel node. The hook accepts a ref and an onIntersect callback and re-subscribes whenever the node or options change. Storing the callback in savedCallback via a ref means the observer does not need to be torn down every time the parent passes a new inline function, which prevents needless observer churn on each render. The rootMargin option lets loading begin slightly before the sentinel is actually visible, giving the fetch a head start so scrolling feels seamless.
In FeedList component, the pieces come together: the hook supplies items and loadMore, and a sentinelRef is attached to an empty div at the bottom of the list. useOnScreen only calls loadMore when hasMore is true and no request is in flight, so the observer stays passive once the feed is exhausted. This pattern is preferable to scroll-event listeners because IntersectionObserver is asynchronous and off the main thread, avoiding jank. The main trade-off is that the sentinel must remain in the layout for the observer to detect it, and empty or short lists need a fallback so loadMore can still be reached.
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
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
import { Application } from "@hotwired/stimulus"
import FormSubmitController from "./controllers/form_submit_controller"
const application = Application.start()
application.debug = false
Disable submit button while Turbo form is submitting
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
url: String,
delay: { type: Number, default: 800 },
Stimulus: autosave draft with Turbo-friendly requests
Share this code
Here's the card — post it anywhere.