Skip to content

Commit a114253

Browse files
committed
docs: simplify the InfiniteList recipe
You don't need the effect. An empty list puts the sentinel inside the viewport, so the observer already asks for the first page. The effect was a second copy of the fetch, the error handling, and the loading flag, racing the observer for the same request. The `loading: true` initial state only existed to referee that race, so it goes too. Four states become three. `loading` and `error` booleans could both be set at once, which is not a state this component has; one `status` union removes it and lets the button derive its own label. The `useCallback` goes as well: the hook reads `onChange` from a ref, so a plain function is enough. The two buttons merge into one that says "Try again" after a failure. Verified against the browser's own IntersectionObserver, not the mock, since the mock does not replay state to a newly created observer and hides the behavior this recipe depends on. Six cases: first page loads with no effect, a short page keeps filling, each page is requested exactly once, observation stops on the last page, the button recovers from an error, and a failure does not turn into a scroll-retry loop. The re-arm is worth knowing about. Flipping `skip` drops and recreates the observer, which is what lets a page too short to push the sentinel out of view keep loading. The prose now says so instead of describing `skip` as only a duplicate-request guard.
1 parent 8a725aa commit a114253

1 file changed

Lines changed: 30 additions & 44 deletions

File tree

‎apps/docs/docs/guides/recipes.mdx‎

Lines changed: 30 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,10 @@ An intersection does not prove anyone actually looked at the content. Add a mini
133133

134134
## Infinite scrolling
135135

136-
Observe a sentinel at the end of the list, guard requests while loading, and keep a real button for keyboard and assistive-technology users:
136+
Observe a sentinel at the end of the list, guard requests while one is in flight, and keep a real button for keyboard and assistive-technology users:
137137

138138
```tsx
139-
import { useCallback, useEffect, useState, type Key, type ReactNode } from "react";
139+
import { useState, type Key, type ReactNode } from "react";
140140
import { useInView } from "react-intersection-observer";
141141

142142
type Page<T> = { items: T[]; hasNextPage: boolean };
@@ -152,68 +152,48 @@ export function InfiniteList<T>({
152152
}) {
153153
const [items, setItems] = useState<T[]>([]);
154154
const [hasNextPage, setHasNextPage] = useState(true);
155-
const [loading, setLoading] = useState(true);
156-
const [error, setError] = useState<Error | null>(null);
155+
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
157156

158-
const loadMore = useCallback(async () => {
159-
if (loading || !hasNextPage) return;
157+
async function loadMore() {
158+
if (status === "loading" || !hasNextPage) return;
160159

161-
setLoading(true);
162-
setError(null);
160+
setStatus("loading");
163161
try {
164162
const page = await loadPage(items.length);
165163
setItems((current) => [...current, ...page.items]);
166164
setHasNextPage(page.hasNextPage);
167-
} catch (cause) {
168-
setError(cause instanceof Error ? cause : new Error("Unable to load"));
169-
} finally {
170-
setLoading(false);
165+
setStatus("idle");
166+
} catch {
167+
setStatus("error");
171168
}
172-
}, [hasNextPage, items.length, loadPage, loading]);
173-
174-
useEffect(() => {
175-
let cancelled = false;
176-
setLoading(true);
177-
void loadPage(0)
178-
.then((page) => {
179-
if (cancelled) return;
180-
setItems(page.items);
181-
setHasNextPage(page.hasNextPage);
182-
})
183-
.catch((cause) => {
184-
if (!cancelled) {
185-
setError(cause instanceof Error ? cause : new Error("Unable to load"));
186-
}
187-
})
188-
.finally(() => {
189-
if (!cancelled) setLoading(false);
190-
});
191-
192-
return () => {
193-
cancelled = true;
194-
};
195-
}, [loadPage]);
169+
}
196170

197171
const { ref } = useInView({
198172
rootMargin: "400px 0px",
199-
skip: loading || !hasNextPage || error !== null,
173+
skip: status !== "idle" || !hasNextPage,
200174
onChange: (inView) => {
201175
if (inView) void loadMore();
202176
},
203177
});
204178

205179
return (
206180
<>
207-
<ul>{items.map((item) => <li key={getKey(item)}>{renderItem(item)}</li>)}</ul>
181+
<ul>
182+
{items.map((item) => (
183+
<li key={getKey(item)}>{renderItem(item)}</li>
184+
))}
185+
</ul>
186+
208187
<p aria-live="polite">
209-
{loading ? "Loading more items…" : error ? "Could not load more items." : ""}
188+
{status === "loading" ? "Loading more items…" : null}
189+
{status === "error" ? "Could not load more items." : null}
210190
</p>
211-
{error ? <button onClick={() => void loadMore()}>Try again</button> : null}
191+
212192
{hasNextPage ? (
213193
<>
214194
<div ref={ref} aria-hidden="true" />
215-
<button disabled={loading} onClick={() => void loadMore()}>
216-
{loading ? "Loading…" : "Load more"}
195+
<button disabled={status === "loading"} onClick={() => void loadMore()}>
196+
{status === "error" ? "Try again" : "Load more"}
217197
</button>
218198
</>
219199
) : null}
@@ -224,6 +204,12 @@ export function InfiniteList<T>({
224204

225205
<ObserverDemo recipe="infinite-list" />
226206

227-
Start with `loading: true` so the sentinel cannot race the first request. After that, the `skip` guard blocks duplicate requests. `rootMargin` starts the load before the sentinel reaches the viewport, so tune the `400px` value to your network and item size. This example sends `items.length` as the next page's `skip`; use whatever pagination your API expects.
207+
There is no effect here, and that is the point. An empty list puts the sentinel inside the viewport, so the observer asks for the first page through the same path as every page after it. An effect for the first page would duplicate the fetch, the error handling, and the loading flag, then race the observer for the same request.
208+
209+
One `status` beats separate `loading` and `error` booleans: the component cannot be loading and failed at the same time, and the retry button reads its own label from it.
210+
211+
`skip` does two jobs. It keeps a second request from starting while one is in flight, and because the hook drops and recreates the observer when `skip` flips back, it re-arms the sentinel. A page too short to push the sentinel out of view keeps loading until the viewport fills.
212+
213+
`rootMargin` starts the request before the sentinel reaches the viewport. Tune the `400px` value to your network and item size. This example sends `items.length` as the next page's `skip`; use whatever pagination your API expects.
228214

229-
Keep the manual "Load more" button and the retry path. Scrolling should never be the only way to fetch content.
215+
Keep the button. It retries after an error, it is the only route for keyboard and assistive-technology users, and it is the guaranteed way forward if the observer never fires again. Scrolling should never be the only way to fetch content.

0 commit comments

Comments
 (0)