typescript 111 lines · 3 tabs

IntersectionObserver infinite scroll hook

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

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

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
typescript
export type Settled<R> =
  | { status: 'fulfilled'; value: R }
  | { status: 'rejected'; reason: unknown };

export interface ConcurrencyOptions {
  limit: number;

Simple concurrency limiter for batch operations

node concurrency async
by codesnips 2 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.

IntersectionObserver infinite scroll hook — share card
Link copied