Infinite list
Append pages to a keyed list when a sentinel scrolls into view, without recreating the rows that are already on screen.
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).
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 ofrows(theFor) and ofloadinganddoneupdate. SeeSetStore.loadMorereturns whilestate.loadingis 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 onlydeps:state.rows.length, so the effect re-runs after each page, andstate.done, which turns the dependency intoundefinedand stops observing once the list ends.fnruns untracked, and the function it returns is the disposer, whichuseEffectruns before the next run and when the component is disposed. - Keyed
Forpasses an accessor, so the row readsrow().label. SeeFor.