[]previous and next page

Infinite list

Append pages to a keyed list when a sentinel scrolls into view, without recreating the rows that are already on screen.

For agentsUpdated

This recipe builds a list that grows by one page each time a sentinel element becomes visible. It demonstrates useStore with the draft and path forms of setStore, the keyed For component, useEffect with a cleanup, and a visibility observer that is passed in as a prop, which is what makes it testable without a browser.

The component#

The list lives in one store. For with key keeps a row's DOM node for as long as its key is in the array, so appending a page only creates the new rows.

The sentinel is an empty element after the list. The component never calls IntersectionObserver directly. It takes an observe(element, onVisible) function that returns a disposer, and defaults to an implementation built on IntersectionObserver that does nothing when the global does not exist (server rendering, happy-dom).

src/infinite-list.tsxtsx
import { useEffect, useStore } from "@shadow-js/core";
import { For, Show } from "@shadow-js/web";

export interface Row {
  id: number;
  label: string;
}

/** Calls `onVisible` when `el` is in view. Returns a function that stops observing. */
export type Observe = (el: Element, onVisible: () => void) => () => void;

export const observeIntersection: Observe = (el, onVisible) => {
  if (typeof IntersectionObserver === "undefined") return () => undefined;
  const observer = new IntersectionObserver((entries) => {
    if (entries.some((entry) => entry.isIntersecting)) onVisible();
  });
  observer.observe(el);
  return () => observer.disconnect();
};

export interface InfiniteListProps {
  /** Loads the rows that follow the first `offset` rows. */
  load: (offset: number) => Promise<readonly Row[]>;
  /** A page shorter than this ends the list. */
  pageSize: number;
  observe?: Observe;
}

export function InfiniteList(props: InfiniteListProps) {
  const [state, setState] = useStore({
    rows: [] as Row[],
    loading: false,
    done: false,
    error: "",
  });
  let sentinel: HTMLDivElement | undefined;

  const loadMore = (): void => {
    if (state.loading || state.done) return;
    setState({ loading: true, error: "" });
    props.load(state.rows.length).then(
      (page) => {
        setState((draft) => {
          draft.rows.push(...page);
          draft.loading = false;
          draft.done = page.length < props.pageSize;
        });
      },
      (cause: unknown) => {
        setState("loading", false);
        setState("error", cause instanceof Error ? cause.message : "Could not load more");
      },
    );
  };

  // Re-observing after every page makes a real IntersectionObserver report a sentinel that is
  // still in view, so a tall screen keeps loading until the page overflows.
  useEffect(
    () => (state.done ? undefined : state.rows.length),
    (count) => {
      if (count === undefined || sentinel === undefined) return;
      return (props.observe ?? observeIntersection)(sentinel, loadMore);
    },
  );

  return (
    <div>
      <ul aria-label="Items">
        <For each={state.rows} key={(row) => row.id}>
          {(row) => <li>{row().label}</li>}
        </For>
      </ul>
      <div
        ref={(el) => {
          sentinel = el;
        }}
        aria-hidden="true"
      />
      <p role="status">{state.loading ? "Loading" : state.done ? "End of list" : ""}</p>
      <Show when={state.error !== ""}>
        <p role="alert">
          {state.error} <button type="button" onClick={loadMore}>Retry</button>
        </p>
      </Show>
    </div>
  );
}

Decisions worth knowing:

  • setState({ loading: true, error: "" }) merges keys into the root object. setState("loading", false) is the path form. setState((draft) => { draft.rows.push(...page); ... }) is the draft form: one batch, and only the readers of rows (the For) and of loading and done update. See SetStore.
  • loadMore returns while state.loading is true. That guard is the only protection against an observer that reports twice while a request is in flight.
  • The effect form useEffect(deps, fn) tracks only deps: state.rows.length, so the effect re-runs after each page, and state.done, which turns the dependency into undefined and stops observing once the list ends. fn runs untracked, and the function it returns is the disposer, which useEffect runs before the next run and when the component is disposed.
  • Keyed For passes an accessor, so the row reads row().label. See For.

Related: useStore, For, useEffect, deferred, flush.