import { useState, useRef, useEffect, useCallback } from 'react';
export function useOnScreen(options = {}) {
const [node, setNode] = useState(null);
const [isIntersecting, setIsIntersecting] = useState(false);
const ref = useCallback((el) => {
setNode(el);
}, []);
const { root = null, rootMargin = '0px', threshold = 0 } = options;
useEffect(() => {
if (!node || typeof IntersectionObserver === 'undefined') return;
const observer = new IntersectionObserver(
([entry]) => setIsIntersecting(entry.isIntersecting),
{ root, rootMargin, threshold }
);
observer.observe(node);
return () => observer.disconnect();
}, [node, root, rootMargin, threshold]);
return [ref, isIntersecting];
}
import { useState, useRef, useCallback } from 'react';
export function useInfiniteFeed(fetchPage) {
const [items, setItems] = useState([]);
const [cursor, setCursor] = useState(null);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const loadingRef = useRef(false);
const loadMore = useCallback(async () => {
if (loadingRef.current || !hasMore) return;
loadingRef.current = true;
setLoading(true);
setError(null);
try {
const { results, nextCursor } = await fetchPage(cursor);
setItems((prev) => [...prev, ...results]);
setCursor(nextCursor);
setHasMore(nextCursor != null);
} catch (err) {
setError(err);
} finally {
loadingRef.current = false;
setLoading(false);
}
}, [fetchPage, cursor, hasMore]);
return { items, hasMore, loading, error, loadMore };
}
import { useEffect } from 'react';
import { useOnScreen } from './useOnScreen';
import { useInfiniteFeed } from './useInfiniteFeed';
async function fetchPosts(cursor) {
const url = new URL('/api/posts', window.location.origin);
if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to load posts: ${res.status}`);
return res.json();
}
export default function Feed() {
const { items, hasMore, loading, error, loadMore } = useInfiniteFeed(fetchPosts);
const [sentinelRef, isVisible] = useOnScreen({ rootMargin: '200px' });
useEffect(() => {
if (isVisible && hasMore && !loading) {
loadMore();
}
}, [isVisible, hasMore, loading, loadMore]);
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 && <p className="feed__error">Something went wrong. Scroll to retry.</p>}
{loading && <p className="feed__status">Loading more…</p>}
{hasMore && <div ref={sentinelRef} className="feed__sentinel" aria-hidden="true" />}
{!hasMore && <p className="feed__end">You've reached the end.</p>}
</div>
);
}
This snippet builds infinite scroll the way it is usually done in real React apps: a small, reusable observer hook that reports visibility, and a page-fetching hook layered on top so the list component stays declarative. The concept is to avoid scroll-position math entirely — instead of listening to scroll events and comparing pixel offsets, an IntersectionObserver watches a sentinel element at the bottom of the list and fires only when it enters the viewport.
In useOnScreen hook, a ref is returned along with a boolean isIntersecting. The effect creates an IntersectionObserver bound to the node in the ref and updates state on each callback. rootMargin is passed through so callers can trigger loading slightly before the sentinel is actually visible, which hides fetch latency. The observer is disconnected in the cleanup function, and the effect re-runs when the node or options change, which matters because the sentinel node may not exist on the first render. Serializing the options into the dependency array keeps the effect stable across renders that pass an equivalent options object.
In useInfiniteFeed hook, pagination state is tracked with items, cursor, hasMore, and loading. loadMore is wrapped in useCallback and guards against concurrent or redundant calls using a loadingRef, since IntersectionObserver can fire rapidly while the sentinel lingers on screen. Each successful fetch appends results and advances the cursor; when nextCursor is null, hasMore flips to false and the sentinel stops mattering. The ref-based guard is important because state updates are asynchronous, so checking loading directly would race.
In Feed component, the two hooks are composed: useOnScreen produces sentinelRef and isVisible, and an effect calls loadMore whenever the sentinel becomes visible and more pages remain. The sentinel is a zero-height div rendered after the list, only while hasMore is true. This design keeps concerns separated — visibility detection, data fetching, and rendering are independent and individually testable. A common pitfall it avoids is attaching the observer to a stale node; because the ref-setter drives the effect, remounting the sentinel re-registers the observer cleanly.
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
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
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.