<!--
Sitemap:
- [rxfy](/index): Typed, normalized, reactive state — built on RxJS
- [Comparison](/comparison): rxfy versus Redux Toolkit, MobX, Jotai, and TanStack Query
- [Agent Skills](/agent-skills): Accurate rxfy context for AI coding assistants
- [Examples](/examples): Runnable apps, from client-only to fully live
- [Changelog](/changelog)
- [Getting Started](/getting-started)
- [Store quickstart](/getting-started/store): Normalized reactive state in a client-only app
- [Framework quickstart](/getting-started/framework): The full stack for a live app
- [Core Concepts](/core-concepts): The ideas rxfy is built on
- [Normalization](/core-concepts/normalization): Store each entity once, reference it by id
- [Late Unwrapping](/core-concepts/late-unwrapping): Unwrap async state at the leaf, not the trunk
- [Server-Side Rendering](/core-concepts/ssr): Dehydrate on the server, hydrate with no refetch
- [rxfy](/rxfy): The core package: atoms, lenses, models, and states
- [createModel](/rxfy/create-model): Typed entities in normalized storage
- [defineState](/rxfy/define-state): Typed, normalized state shapes with fetching and mutations
- [createAtom](/rxfy/create-atom): A reactive cell with synchronous get, set, and modify
- [createLens](/rxfy/create-lens): A two-way view into part of an Atom
- [React Bindings](/react): Hooks and helpers for using rxfy in React
- [useStateData](/react/use-state-data): Fetch, normalize, and subscribe to a query
- [useStatePagedData](/react/use-state-paged-data): Paginated and infinite-scroll lists
- [useModelStore](/react/use-model-store): Subscribe to one normalized entity by id
- [useAtom](/react/use-atom): Two-way binding for any IAtom
- [Pending](/react/pending): Render pending, rejected, and fulfilled UI for any observable
- [usePending](/react/use-pending): The status value behind Pending
- [useObservable](/react/use-observable): Bind a raw Observable to React
- [Live client](/react/live-client): Wire a WebSocket transport into StoreProvider
- [rxfy-server](/framework/server): Bind Drizzle tables, write, and publish live updates
- [defineResource](/framework/server/define-resource): Tie a Drizzle table to an rxfy model
- [createServer](/framework/server/create-server): Wire db, resources, hub, and keyer into a Live object
- [Writes](/framework/server/writes): live.create / live.update / live.delete and touch
- [Live messages](/framework/server/messages): What travels between server and client
- [Grants & live hydration](/framework/server/grants): Hand off an SSR render to a live client
- [createTopicKeyer](/framework/server/create-topic-keyer): Time-windowed HMAC ids for topics
- [createInMemoryHub](/framework/server/hub): The pub/sub backbone
- [rxfy-ws](/framework/ws): The default WebSocket transport
- [createWsServer](/framework/ws/server): Attach a Hub to WebSocket connections
- [createWsClient](/framework/ws/client): The browser transport with reconnect and replay
- [Custom transports](/framework/ws/custom-transport): Bring your own ClientTransport
- [Guides](/guides): Task-focused walkthroughs of common rxfy patterns
- [Build a Todo app](/guides/todo-app): A guided, end-to-end first app
- [Pagination and infinite scroll](/guides/pagination): Load and append pages into one normalized list
- [Live blog](/guides/live-blog): Build a real-time blog: SSR, then live patches and stale badges
-->

# Pagination and infinite scroll \[Load and append pages into one normalized list]

Load a list one page at a time and append each page into a single growing list, with every
row normalized like any other entity. [`useStatePagedData`](/react/use-state-paged-data) owns the
whole flow: page 0 is fetched, cached, and SSR'd through `useStateData`, while `loadMore()`
fetches later pages and appends them for you.

The cursor is **view state**, not entity data; it tells you where to resume, so it lives in the
hook (derived by `getCursor`), outside the normalized store.

For a runnable version of everything below — an infinite list with a Load-more button, an
infinite-scroll sentinel, and streaming SSR — see the
[vite-ssr-pagination example](/examples#vite-ssr-pagination).

## Page with `useStatePagedData`

Give the hook the `model` to page (the list is always `array(model)`), a `fetchPage` (fetch one
page for a cursor), a `getCursor` (where to resume — it receives the current `ids: string[]`), and
a `select` (the entities a page contributes, appended to the list). Page 0 is just
`select(firstPage)`, so it goes through `useStateData`'s cache / SSR path unchanged. Each later
page does O(page-size) work — the rows already loaded are never re-normalized.

```tsx
import { useMemo } from "react";
import { useStatePagedData, Pending } from "rxfy-react";
import { FeedItemModel } from "./feed";
import { api } from "./api";

function Feed() {
  const params = useMemo(() => ({ filter: "all" as const }), []);

  const { data$, loadMore, isLoading, hasMore } = useStatePagedData({
    model: FeedItemModel,
    key: "feed",
    params,
    // Offset cursor = number of rows already loaded. Page 0 gets cursor 0.
    fetchPage: ({ cursor, params }) => api.feed({ ...params, offset: cursor }),
    getCursor: ({ ids }) => ids.length,
    select: ({ page }) => page.items,
    hasMore: ({ page }) => page.items.length > 0,
  });

  return (
    <Pending value$={data$} pending={<p>Loading…</p>}>
      {(ids) => (
        <>
          <ul>{ids.map((id) => <FeedRow key={id} id={id} />)}</ul>
          {hasMore && (
            <button onClick={() => loadMore()} disabled={isLoading}>
              {isLoading ? "Loading…" : "Load more"}
            </button>
          )}
        </>
      )}
    </Pending>
  );
}
```

`data$` emits a flat `string[]` of ids, so each page's new ids append to the list while the row
data lives in the `FeedItemModel` store. A row that appears on two pages (a duplicate the server
returned) still resolves to one shared entity cell. `loadMore` guards against overlapping calls
and becomes a no-op once `hasMore` returns `false`.

## Cursor strategies

`getCursor` returns whatever your API needs; its type is inferred as `TCursor` and flows into
`fetchPage`.

* **Offset** — `getCursor: ({ ids }) => ids.length`. Hydration-safe: it doesn't depend on
  page 0 re-running on the client.
* **Page number** — `getCursor: ({ pageIndex }) => pageIndex` (page 0 fetched as index 0, the
  first `loadMore` as index 1, …).
* **Opaque token** — return a token your API gave you. If the token lives on an entity field
  (keyset pagination), `getCursor` only sees ids, not entities — drop down to `useStateData` +
  manual `set` for that case.

## Drive it from a scroll sentinel

Swap the button for an `IntersectionObserver` to load the next page as the user reaches the
bottom:

```tsx
import { useEffect, useRef } from "react";

function LoadMoreSentinel({ onVisible }: { onVisible: () => void }) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) onVisible();
    });
    io.observe(el);
    return () => io.disconnect();
  }, [onVisible]);

  return <div ref={ref} />;
}
```

Render `<LoadMoreSentinel onVisible={() => loadMore()} />` after the list. No overlap guard is
needed — `loadMore` ignores calls made while a page is already in flight.

## Notes

* **Keep the cursor out of `params`.** The cursor is derived by `getCursor`, not a `useStateData`
  param. If it were a param, each page would be a separate cached query and replace the previous
  list instead of extending it. Stable `params` is what accumulates one list.
* **A new filter resets the list.** Changing `params` (a different `filter`) starts a fresh query —
  the hook resets pagination and refetches page 0 of the new filter automatically.
* **Reloading.** `reload()` re-fetches the first page only and resets pagination. To restore a
  deep scroll position after a reload, re-run your `loadMore` calls.
* **Need full control?** For anything `useStatePagedData` can't express (keyset cursors over
  entity fields, prepending, bidirectional paging), use [`useStateData`](/react/use-state-data)
  directly and append pages with `set((prev) => …)`.
