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 sanitizeHtml from "sanitize-html"; interface RichTextOptions { allowImages?: boolean;
Building a configurable HTML sanitizer allowlist
sanitization
xss-prevention
allowlist
Intermediate
7 steps
ruby
require "net/http" require "json" require "uri" require "base64"
Paginating an HTTP API with a Ruby enumerator
pagination
http
enumerator
Intermediate
7 steps
typescript
type NestedValue = string | NestedValue[] | { [key: string]: NestedValue }; function parseFieldPath(name: string): string[] { const match = name.match(/^([^\[\]]+)((?:\[[^\[\]]*\])*)$/);
Parsing bracketed form field names into nested objects
parsing
recursive-types
regex
Intermediate
8 steps
typescript
import { Component } from '@angular/core'; import { NgForm } from '@angular/forms'; interface SignupModel {
How template-driven forms validate in Angular
forms
two-way-binding
validation
Intermediate
9 steps
javascript
import { useState, useEffect, useCallback } from 'react'; export function useCountdown(initialSeconds) { const [secondsLeft, setSecondsLeft] = useState(initialSeconds);
Building a useCountdown hook in React
custom-hooks
state-management
side-effects
Intermediate
8 steps
go
package batch import "fmt"
Splitting a slice into batches in Go
generics
slices
error-handling
Intermediate
6 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.