Skip to content

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

PrimitiveRole
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 refsThe 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

  • UtilitiescreateIntersectionObserver, <dom-lifecycle>.
  • Data fetchingasync controller and onCleanup-based abort.
  • ListsFor reconciliation rules.
  • AsyncAsync.state semantics for the pending guard.