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 scrollTop on every scroll tick, you watch one element. rootMargin: "200px" triggers the load before the user actually hits the end, so it feels seamless.
  • The ref owns the observer. The ref callback receives the real sentinel element after it's inserted and returns a cleanup; Zoijs runs that cleanup on unmount, so the IntersectionObserver is always disconnected — no leak. See Bindings.
  • Append, don't replace. items.set([...items.get(), ...next]) grows the array, and the keyed each reuses every row already on screen — only the new <li>s are created. Scroll position and focus are preserved.
  • Guards prevent double-loads. loading and done flags make loadMore() 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.