Skip to content

Commit 14fc539

Browse files
committed
docs: use useTransition in the InfiniteList recipe
Yes, it is the right hook, and for a better reason than ergonomics. The hand-rolled status had a real bug. When `loadPage` resolves from a warm cache without ever yielding, React batches the update that sets the loading flag together with the one that clears it. `skip` never changes, so the observer never re-arms and the list stops after one page. Measured on the same input: the status version requests [0], this one requests [0, 1, 2, 3, 4]. `isPending` is raised by React when `startTransition` runs and lowered when the awaited work settles, so it renders either way. It also removes the `setLoading(false)` that has to be repeated on every exit path, and marking the append as a transition keeps a list of hundreds of rows from blocking a click. It also answers the floating-promise objection properly rather than by deleting `async`: `startTransition` awaits the function it is given, so the async work now has an owner. Async transitions need React 19, so the recipe carries a note telling React 18 readers to track the status themselves. Verified against the browser's own observer, eight cases: first page with no effect, a short page keeps filling, a warm cache keeps filling, each page requested exactly once, observation stops on the last page, the live region announces pending then failure, the button recovers, and a failure does not become a scroll-retry loop.
1 parent b65f25b commit 14fc539

1 file changed

Lines changed: 24 additions & 18 deletions

File tree

apps/docs/docs/guides/recipes.mdx

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ An intersection does not prove anyone actually looked at the content. Add a mini
136136
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 { useState, type Key, type ReactNode } from "react";
139+
import { useState, useTransition, type Key, type ReactNode } from "react";
140140
import { useInView } from "react-intersection-observer";
141141

142142
type Page<T> = { items: T[]; hasNextPage: boolean };
@@ -152,25 +152,27 @@ export function InfiniteList<T>({
152152
}) {
153153
const [items, setItems] = useState<T[]>([]);
154154
const [hasNextPage, setHasNextPage] = useState(true);
155-
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
155+
const [failed, setFailed] = useState(false);
156+
const [isPending, startTransition] = useTransition();
156157

157158
function loadMore() {
158-
if (status === "loading" || !hasNextPage) return;
159+
if (isPending || !hasNextPage) return;
159160

160-
setStatus("loading");
161-
loadPage(items.length).then(
162-
(page) => {
161+
setFailed(false);
162+
startTransition(async () => {
163+
try {
164+
const page = await loadPage(items.length);
163165
setItems((current) => [...current, ...page.items]);
164166
setHasNextPage(page.hasNextPage);
165-
setStatus("idle");
166-
},
167-
() => setStatus("error"),
168-
);
167+
} catch {
168+
setFailed(true);
169+
}
170+
});
169171
}
170172

171173
const { ref } = useInView({
172174
rootMargin: "400px 0px",
173-
skip: status !== "idle" || !hasNextPage,
175+
skip: isPending || failed || !hasNextPage,
174176
onChange: (inView) => {
175177
if (inView) loadMore();
176178
},
@@ -185,15 +187,15 @@ export function InfiniteList<T>({
185187
</ul>
186188

187189
<p aria-live="polite">
188-
{status === "loading" ? "Loading more items…" : null}
189-
{status === "error" ? "Could not load more items." : null}
190+
{isPending ? "Loading more items…" : null}
191+
{failed ? "Could not load more items." : null}
190192
</p>
191193

192194
{hasNextPage ? (
193195
<>
194196
<div ref={ref} aria-hidden="true" />
195-
<button disabled={status === "loading"} onClick={loadMore}>
196-
{status === "error" ? "Try again" : "Load more"}
197+
<button disabled={isPending} onClick={loadMore}>
198+
{failed ? "Try again" : "Load more"}
197199
</button>
198200
</>
199201
) : null}
@@ -204,11 +206,15 @@ export function InfiniteList<T>({
204206

205207
<ObserverDemo recipe="infinite-list" />
206208

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.
209+
:::note[React 19]
210+
Passing an async function to `startTransition` needs React 19. On React 18, only the updates you schedule before the first `await` are part of the transition, so `isPending` clears too early. Track a `"idle" | "loading" | "error"` status yourself there.
211+
:::
212+
213+
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 pending flag, then race the observer for the same request.
208214

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.
215+
`useTransition` owns that pending flag. React raises `isPending` when `startTransition` runs and lowers it when the awaited work settles, so there is no `setLoading(true)` to pair with a `setLoading(false)` on every exit path. `startTransition` also awaits the async function you hand it, so nothing floats. Marking the append as a transition is worth it on its own: re-rendering a list that has grown to hundreds of rows no longer blocks a click or a keystroke.
210216

211-
`loadMore` is not `async`. Nothing awaits it, so returning a promise would only invite a caller to try. It starts the request and reports the outcome through state, which is what both the observer and the button want.
217+
A hand-rolled loading boolean is also less reliable here. When `loadPage` resolves from a warm cache without ever yielding, React can batch the update that sets the flag together with the one that clears it. The observer then never sees `skip` change, and the list stops after one page. `isPending` renders either way.
212218

213219
`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.
214220

0 commit comments

Comments
 (0)