javascript
58 lines · 8 steps
Cursor-based infinite scroll with a React hook
A custom hook wraps cursor pagination state and fetching, so a component can render a Load More list without touching the plumbing.
Explained by
highlit
1import { useState, useCallback, useEffect } from 'react';
2
3function usePaginatedProducts(pageSize = 20) {
4 const [items, setItems] = useState([]);
5 const [cursor, setCursor] = useState(null);
6 const [hasMore, setHasMore] = useState(true);
7 const [isLoading, setIsLoading] = useState(false);
8 const [error, setError] = useState(null);
9
10 const fetchPage = useCallback(async (nextCursor) => {
11 setIsLoading(true);
12 setError(null);
13 try {
14 const params = new URLSearchParams({ limit: String(pageSize) });
15 if (nextCursor) params.set('cursor', nextCursor);
16 const res = await fetch(`/api/products?${params}`);
17 if (!res.ok) throw new Error(`Request failed: ${res.status}`);
18 const { data, nextCursor: newCursor } = await res.json();
19 setItems((prev) => (nextCursor ? [...prev, ...data] : data));
20 setCursor(newCursor);
21 setHasMore(Boolean(newCursor));
22 } catch (err) {
23 setError(err);
24 } finally {
25 setIsLoading(false);
26 }
27 }, [pageSize]);
28
29 useEffect(() => {
30 fetchPage(null);
31 }, [fetchPage]);
32
33 const loadMore = useCallback(() => {
34 if (!isLoading && hasMore) fetchPage(cursor);
35 }, [fetchPage, cursor, isLoading, hasMore]);
36
37 return { items, isLoading, error, hasMore, loadMore };
38}
39
40export default function ProductList() {
41 const { items, isLoading, error, hasMore, loadMore } = usePaginatedProducts();
42
43 return (
44 <div className="product-list">
45 <ul>
46 {items.map((product) => (
47 <li key={product.id}>{product.name}</li>
48 ))}
49 </ul>
50 {error && <p role="alert">Failed to load products.</p>}
51 {hasMore && (
52 <button onClick={loadMore} disabled={isLoading}>
53 {isLoading ? 'Loading\u2026' : 'Load More'}
54 </button>
55 )}
56 </div>
57 );
58}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Packaging related state and side effects into a custom hook keeps components focused on rendering.
- 2Cursor pagination appends new pages and stops when the server returns no next cursor.
- 3Guarding fetches with isLoading and hasMore prevents duplicate or pointless requests.
Related explainers
javascript
const express = require('express'); const router = express.Router(); const { pool } = require('../db'); const redis = require('../redis');
Building a health check endpoint in Express
health-check
timeouts
promise-race
Intermediate
9 steps
javascript
'use client'; import { useEffect } from 'react'; import * as Sentry from '@sentry/nextjs';
How a Next.js error boundary recovers
error-boundary
error-handling
observability
Intermediate
8 steps
java
@Repository public interface OrderRepository extends JpaRepository<Order, Long> { @Query("""
Keyset pagination with Spring Data JPA
pagination
cursor
jpa
Advanced
8 steps
javascript
const MAX_BATCH_SIZE = 20; const FLUSH_INTERVAL = 5000; const ENDPOINT = '/api/analytics';
Batching analytics events in the browser
batching
buffering
sendbeacon
Intermediate
10 steps
javascript
export function diffIds(current, desired) { const currentSet = new Set(current); const desiredSet = new Set(desired);
Diffing sets to sync group membership
set-operations
diffing
transactions
Intermediate
8 steps
javascript
class HistoryStack { constructor(initial = '', limit = 100) { this.past = []; this.present = initial;
Building an undo/redo history stack
undo-redo
state-management
debouncing
Intermediate
7 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/cursor-based-infinite-scroll-with-a-react-hook-explained-javascript-49ad/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.