typescript 48 lines · 9 steps

A cursor-based infinite scroll hook in React

A custom React hook that loads paginated data automatically as a sentinel element scrolls into view.

Explained by highlit
1import { useCallback, useEffect, useRef, useState } from "react";
2 
3interface Page<T> {
4 items: T[];
5 nextCursor: string | null;
6}
7 
8export function useInfiniteScroll<T>(
9 fetchPage: (cursor: string | null) => Promise<Page<T>>,
10) {
11 const [items, setItems] = useState<T[]>([]);
12 const [loading, setLoading] = useState(false);
13 const cursorRef = useRef<string | null>(null);
14 const hasMoreRef = useRef(true);
15 const sentinelRef = useRef<HTMLDivElement | null>(null);
16 
17 const loadMore = useCallback(async () => {
18 if (loading || !hasMoreRef.current) return;
19 setLoading(true);
20 try {
21 const page = await fetchPage(cursorRef.current);
22 setItems((prev) => [...prev, ...page.items]);
23 cursorRef.current = page.nextCursor;
24 hasMoreRef.current = page.nextCursor !== null;
25 } finally {
26 setLoading(false);
27 }
28 }, [fetchPage, loading]);
29 
30 useEffect(() => {
31 const node = sentinelRef.current;
32 if (!node) return;
33 
34 const observer = new IntersectionObserver(
35 (entries) => {
36 if (entries[0].isIntersecting) {
37 void loadMore();
38 }
39 },
40 { rootMargin: "400px" },
41 );
42 
43 observer.observe(node);
44 return () => observer.disconnect();
45 }, [loadMore]);
46 
47 return { items, loading, hasMore: hasMoreRef.current, sentinelRef };
48}
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1Cursor pagination pairs naturally with a ref so each fetch reads the latest position without triggering re-renders.
  2. 2IntersectionObserver with a rootMargin prefetches the next page before the user reaches the bottom.
  3. 3Storing 'more data exists' in a ref avoids stale closures and keeps the guard logic out of the render cycle.

Related explainers

typescript
import { registerLocaleData } from '@angular/common';
import localeFr from '@angular/common/locales/fr';
import localeFrExtra from '@angular/common/locales/extra/fr';
import localeDe from '@angular/common/locales/de';

Locale-aware bootstrapping in Angular

i18n localization dependency-injection
Intermediate 8 steps
typescript
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import * as Joi from 'joi';
 

Validating env config at boot in NestJS

configuration schema-validation environment-variables
Intermediate 8 steps
typescript
import { Inject, Injectable, Logger } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { InjectRepository } from '@nestjs/typeorm';

A cache-aside country lookup in NestJS

cache-aside dependency-injection batch-lookup
Intermediate 8 steps
typescript
import { Injectable, effect, signal, computed } from '@angular/core';
 
interface Preferences {
  theme: 'light' | 'dark';

A signal-based preferences store in Angular

signals state-management persistence
Intermediate 7 steps
javascript
import { useState, useEffect, useCallback, useRef } from 'react';
 
const cache = new Map();
const inflight = new Map();

Building a stale-while-revalidate hook in React

caching request-deduplication custom-hooks
Advanced 10 steps
typescript
import { useEffect, useState } from "react";
 
interface Section {
  id: string;

Building a scroll-spy hook in React

custom-hooks intersectionobserver dom-observation
Intermediate 8 steps

Share this explainer

Here's the card — post it anywhere.

A cursor-based infinite scroll hook in React — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code