javascript 100 lines · 3 tabs

Infinite Scroll in React with an IntersectionObserver useOnScreen Hook

Shared by codesnips Aug 2026
3 tabs
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];
}
3 files · javascript Explain with highlit

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

ruby
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

rails turbo hotwire
by codesnips 4 tabs
ruby
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 performance streaming
by codesnips 3 tabs
ruby
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

rails performance activerecord
by Alex Kumar 2 tabs
javascript
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

rails hotwire stimulus
by codesnips 4 tabs
ruby
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

rails caching performance
by Alex Kumar 1 tab
typescript
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

react axios api
by Maya Patel 1 tab

Share this code

Here's the card — post it anywhere.

Infinite Scroll in React with an IntersectionObserver useOnScreen Hook — share card
Link copied