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
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Cursor pagination pairs naturally with a ref so each fetch reads the latest position without triggering re-renders.
- 2IntersectionObserver with a rootMargin prefetches the next page before the user reaches the bottom.
- 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.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/a-cursor-based-infinite-scroll-hook-in-react-explained-typescript-c3a5/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.