Infinite scroll
Sentinel-driven pagination with createIntersectionObserver + async + a cursor signal. No scroll listeners, no rAF, no manual viewport math.
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.
import { signal } from "elements-kit/signals";
import { async } from "elements-kit/utilities/async";
import { fetchPage, type Item } from "./api";
export const items = signal<Item[]>([]);
const cursor = signal<number>(0);
export const done = signal(false);
export 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);
});
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();
// }
// };Use run(), not start(), for sentinel-driven loads
async(fn).start() ties the body to a tracking effect — every signal read inside the body becomes a dependency. Here the body reads cursor() and writes cursor(page.next). With start(), that write re-triggers the effect, which reads the new cursor, fetches the next page, writes again — every page loads in one cascade.
// ❌ start() — body re-runs on its own cursor write, cascades through every page
loadMore.start();
// ✅ run() — one-shot, untracked. Each call fetches exactly one page.
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
Guard against re-entrant fires
A sentinel that's already in view when the previous page arrives will trigger again immediately. Check loadMore.state !== "pending" and the done latch before calling .run() — without the guard you'll fire two fetches per page.
Set root to the scrollable container
The default observer root is the viewport, not your scrollable <div>. Without root, a sentinel that fits inside the viewport stays "intersecting" and loadMore.run() fires on every page until the list runs out. Pass root: containerEl so the observer measures against the container's scrollport instead.
Order of ref callbacks: child first, then parent
A child element's ref fires after its props/children are attached but before insertion into the parent. The parent's ref fires later, after all its children have been attached. So a child ref can stash a reference; the parent ref can read it. Don't reverse the order.
Replace, don't mutate
items().push(…) won't notify subscribers. Always assign a new array: items([...items(), …page.items]).
See also
- Observers —
createIntersectionObserver,<dom-lifecycle>. - Data fetching —
asynccontroller andonCleanup-based abort. - Lists —
Forreconciliation rules. - Async —
Async.statesemantics for the pending guard.
Debounced search
Search-as-you-type with createDebounced + async + AbortController. The fetch tracks the debounced signal, so each keystroke restarts cleanly.
Context propagation
Share a current-user signal with deeply-nested components via setContext + getContext, deferring the lookup with <dom-lifecycle> so it resolves after mount.