Infinite scroll
Load the next page when the user reaches the bottom — using an IntersectionObserver on a sentinel element (no scroll-event math), a keyed each so existing rows are never rebuilt, and onCleanup so the observer is always disconnected.
import { html, mount, createState, each, onCleanup } from "@zoijs/core";
const PAGE = 20;
const fetchPage = (page) =>
fetch(`/api/items?page=${page}&size=${PAGE}`).then((r) => r.json());
function Feed() {
const items = createState([]);
const page = createState(0);
const loading = createState(false);
const done = createState(false); // no more pages
async function loadMore() {
if (loading.get() || done.get()) return;
loading.set(true);
try {
const next = await fetchPage(page.get());
items.set([...items.get(), ...next]);
page.set(page.get() + 1);
if (next.length < PAGE) done.set(true);
} finally {
loading.set(false);
}
}
loadMore(); // first page
// Observe the sentinel; load when it scrolls into view.
const sentinel = (el) => {
const io = new IntersectionObserver(
(entries) => entries[0].isIntersecting && loadMore(),
{ rootMargin: "200px" } // start a little early
);
io.observe(el);
return () => io.disconnect(); // ref cleanup: runs on unmount
};
return html`
<section>
<ul>
${each(
() => items.get(),
(it) => it.id,
(it) => html`<li>${it.title}</li>`
)}
</ul>
${() => loading.get() && html`<p aria-live="polite">Loading…</p>`}
${() => done.get() && html`<p>That's everything.</p>`}
<!-- The sentinel: when it enters the viewport, fetch the next page. -->
<div ref=${sentinel} style="height:1px"></div>
</section>
`;
}
mount(Feed, "#app");Notes#
- A sentinel beats scroll events. Instead of measuring
scrollTopon every scroll tick, you watch one element.rootMargin: "200px"triggers the load before the user actually hits the end, so it feels seamless. - The
refowns the observer. Therefcallback receives the real sentinel element after it's inserted and returns a cleanup; Zoijs runs that cleanup on unmount, so theIntersectionObserveris always disconnected — no leak. See Bindings. - Append, don't replace.
items.set([...items.get(), ...next])grows the array, and the keyedeachreuses every row already on screen — only the new<li>s are created. Scroll position and focus are preserved. - Guards prevent double-loads.
loadinganddoneflags makeloadMore()idempotent, so a burst of intersection callbacks still fetches each page once.
For a windowed / virtualized list (thousands of rows), keep this loader but render only a slice of items around the scroll position — the keyed each still reuses nodes as the window moves.
Next: File upload.