Infinite scroll
Append the next page when the bottom sentinel scrolls into view. IntersectionObserver does the visibility math; an async controller drives the fetch; cleanup is automatic when the surrounding scope disposes.
The recipe
import { signal } from "elements-kit/signals";import { async } from "elements-kit/utilities/async";import { createIntersectionObserver } from "elements-kit/utilities/intersection-observer";import { For } from "elements-kit/for";
type Item = { id: number; label: string };
const items = signal<Item[]>([]);const cursor = signal(0);const done = signal(false);
const loadMore = async(async () => { const c = cursor(); const page = await fetchPage(c); items([...items(), ...page.items]); if (page.next == null) done(true); else cursor(page.next);});loadMore.run(); // initial page
// In JSX — capture sentinel from a child ref, wire observer from the parent ref:// let sentinel!: Element;// <div ref={(root) => createIntersectionObserver(sentinel, cb, {// root: root as HTMLElement,// rootMargin: "100px",// })}// style="height: 280px; overflow: auto;">// <ul><For each={items} by={(i) => i.id}>{…}</For></ul>// <div ref={(el) => (sentinel = el)} style="height: 1px;" />// </div>//// const cb = ([entry]: IntersectionObserverEntry[]) => {// if (entry.isIntersecting && !done() && loadMore.state !== "pending") {// loadMore.run();// }// };How each piece contributes
| Primitive | Role |
|---|---|
signal<Item[]> | The accumulating list. Replaced wholesale on append ([...items(), …]) so subscribers re-run. |
signal(cursor) | Server-defined offset. Returned alongside next so the page boundary is the server’s choice. |
signal(done) | One-way latch — flips to true when next == null, stops the observer from firing more loads. |
async(fn) | Mutation-shaped controller. .run() runs the body once, untracked — the right call when an external event (intersection, click) drives the load. .start() would re-run on signal writes the body itself performs. loadMore.state is the pending guard. |
createIntersectionObserver(sentinel, cb, { root, rootMargin }) | Auto-cleanup observer. root is the scrollable container; rootMargin preloads slightly ahead of its edge. |
Two refs | The child ref captures the sentinel; the parent ref runs after — by then the sentinel is already in parentElement and we have both pieces to wire the observer. |
Variations
Reset on filter change — clear items and cursor from an effect, then loadMore.run():
const filter = signal("");effect(() => { filter(); // tracked items([]); cursor(0); done(false); loadMore.run();});Manual fallback button — a “load more” button keeps the recipe usable without a sentinel:
<button on:click={() => loadMore.run()} disabled={() => loadMore.state === "pending"}> load more</button>Preload aggressively — bump rootMargin to fire before the user reaches the bottom:
createIntersectionObserver(el, cb, { rootMargin: "400px" });Gotchas
See also
- Utilities —
createIntersectionObserver,<dom-lifecycle>. - Data fetching —
asynccontroller andonCleanup-based abort. - Lists —
Forreconciliation rules. - Async —
Async.statesemantics for the pending guard.