Skip to content

Remote Data Model

When a table’s rows live behind an API and there are too many to load up front — paginated catalogs, search results, infinite feeds — remoteModel() is the model that handles the back-and-forth with your backend.

You give it a fetch function that loads a slice of rows. The model calls that function at the right moments (the user scrolled into rows that haven’t loaded yet, a search changed, an action fired), feeds the results back into the table, and cancels requests that have been overtaken by newer ones.

const [model] = useState(() =>
  remoteModel({
    fetch: fetchProducts,
    initialParams: { category: 'all', search: '' },
    onViewportChange: defaultOffsetViewportHandler,
    pageSize: 50,
    placeholder: loadingProduct,
  })
)

The model carries live state across renders — requests still in flight, the controllers that can cancel them, persisted action payloads, and the bookkeeping that decides what happens when actions overlap. Recreate the model on every render and that state is thrown away: pending fetches get aborted mid-flight and the next render starts the cycle over with fresh ones. The instance has to outlive renders.

For most remote tables, useState with lazy initialization is the right place:

const [model] = useState(() =>
  remoteModel<Product, Query>({
    fetch: fetchProducts,
    initialParams: { category, search },
    onViewportChange: defaultOffsetViewportHandler,
    pageSize: 50,
  })
)

useState’s lazy initializer runs exactly once per mount, and React guarantees the value survives every subsequent render. Use this whenever initial params come from URL state, props, or anything else the surrounding component has access to but a module does not.

Module scope works for genuinely static config — a fixed endpoint, no per-route params, one table per page:

const model = remoteModel({
  fetch: fetchProducts,
  initialParams: { category: 'all', search: '' },
  onViewportChange: defaultOffsetViewportHandler,
  pageSize: 50,
})

Be aware that every mount of every consumer shares the same instance, and therefore the same fetch lifecycle and persisted action state. That can be a feature (cached results survive navigation) or a footgun (two routes appearing to share filter state).

Avoid useMemo(() => remoteModel(...), []). useMemo is contractually a performance hint — React reserves the right to discard the memoized value, which for a remote model means cancelling pending fetches and resetting concurrency state silently. useState is the only hook with a retention guarantee.

The remaining examples on this page write const model = remoteModel({...}) for brevity, focusing each snippet on the option it’s introducing. In real code, wrap the call in useState(() => ...) or place it at module scope using the rules above.

remoteModel ships two built-in patterns for deciding what to fetch as the user scrolls. The right choice depends on how your backend serves data and what kind of scrolling experience the table should deliver.

Reach for offset mode when the backend accepts (offset, limit) slices and can tell you how many rows exist in total. The table renders placeholder rows for everything that hasn’t been fetched, so the scrollbar reflects the full dataset, the user can jump anywhere via the scrollbar or keyboard, and each slice is requested as it enters the viewport.

const model = remoteModel({
  fetch: fetchProducts,
  onViewportChange: defaultOffsetViewportHandler,
  pageSize: 50,
})

This is what most search-and-filter UIs want: product catalogs, admin tables, anything backed by a SQL LIMIT/OFFSET or an Elasticsearch from/size query.

Reach for append mode when the backend is cursor-based, the total isn’t known up front, or new rows arrive over time — social feeds, message timelines, activity logs.

const model = remoteModel({
  mode: 'append',
  fetch: fetchFeed,
  onViewportChange: defaultAppendViewportHandler,
  pageSize: 30,
})

Rows accumulate as the user approaches the end of what’s already loaded. The scrollbar only covers the loaded portion because the table can’t know how many rows are out there; the loading state shows a footer at the bottom while the next page is fetched.

When neither default matches — the backend uses prefetch hints, variable page sizes, or needs requests coalesced into fewer round-trips — write a custom onViewportChange. Given the rendered range and the page size, return one or more slices for the model to fetch:

const model = remoteModel({
  fetch: fetchProducts,
  onViewportChange: ({ endIndex, pageSize, totalCount }) => {
    const nextOffset = Math.floor((endIndex + 1) / pageSize) * pageSize

    if (nextOffset < totalCount) {
      return {
        fetch: [{ offset: nextOffset, limit: Math.min(pageSize, totalCount - nextOffset) }],
      }
    }
  },
  pageSize: 50,
})

Offset-mode handlers return { fetch: [{ offset, limit }, ...] }; append-mode handlers return { loadMore: true }.

async function fetchProducts({ offset, limit, params, signal }) {
  const response = await fetch(`/api/products?offset=${offset}&limit=${limit}&search=${params.search}`, {
    signal,
  })

  return response.json()
}

Offset mode expects { rows, totalCount }. Add groups when the backend returns a flattened grouped result:

return {
  rows: result.rows,
  groups: result.groups,
  totalCount: result.totalCount,
}

For backends that define the row schema, return schema metadata next to the rows or keep a separate field list in application state — see Generating Columns at Runtime.

pageSize controls the slice size. Big enough that ordinary scrolling doesn’t request on every small move, small enough that refreshes stay responsive.

placeholder is the row shape used while first-page rows are still loading. Include every field your column renderers read.

Remote actions translate UI intent into the next request’s params. URL query parameters are the common case; the same pattern works for cursor filters, request bodies, search objects, or anything else fetch reads from params.

const [model] = useState(() =>
  remoteModel({
    fetch: fetchProducts,
    initialParams: { category: 'all', search: '' },
    actions: {
      search: {
        strategy: 'supersede',
        persistence: true,
        handler: ({ params, payload }) => ({ ...params, search: String(payload) }),
      },
      category: {
        persistence: { key: 'categoryFilter' },
        handler: ({ params, payload }) => ({ ...params, category: String(payload) }),
      },
    },
  })
)

model.send({ action: 'search', payload: 'desk' })

Returning new params triggers a refresh. With persistence: true, the payload of each action — the search string, the selected category — is saved alongside the rest of the table’s persisted state and replayed through the handler the next time the table loads, so users come back to the same filtered view. Pass capture and restore instead of true when you need a custom serialized shape.

strategy controls what happens when an action fires while a fetch is still in flight.

const [model] = useState(() =>
  remoteModel({
    fetch: fetchProducts,
    actions: {
      search: {
        strategy: 'supersede',
        handler: ({ params, payload }) => ({ ...params, search: String(payload) }),
      },
      exportRows: {
        strategy: 'queue',
        handler: ({ params, payload }) => ({ ...params, exportId: String(payload) }),
      },
    },
  })
)
  • supersede — cancels the active request for the same action and uses the latest payload. Right for search boxes and filters.
  • queue — runs every request in order. Right for ordered workflows where no request can be dropped.
  • deduplicate — collapses identical concurrent requests. Right for retry buttons and repeated load commands.

Every fetch call receives an AbortSignal. Pass it to fetch() or your client library so superseded refreshes, viewport requests, and disconnects cancel cleanly.

async function fetchProducts({ offset, limit, params, signal }) {
  const response = await fetch(`/api/products?offset=${offset}&limit=${limit}&q=${params.search}`, {
    signal,
  })
  return response.json()
}

onError fires for failed fetches. Aborted requests publish a loading cancellation event instead.

While a fetch is in flight the model emits loading events that the table turns into visible UI — a placeholder for the first load, an overlay for refreshes, and a footer for append-mode pagination. The shadcn wrapper renders sensible defaults for all three slots, so most tables don’t need to handle loading state by hand. Reach for the events directly only when something outside the table — a global progress bar, a status badge, a logging hook — needs to react to the same lifecycle.

Three phases drive the visible slots:

  • initial — first unresolved dataset; rendered as LoadingPlaceholder.
  • refresh — request params changed while old rows stay visible; rendered as LoadingOverlay.
  • end — append mode is fetching more rows below the current data; rendered as LoadingFooter.

Offset viewport fetches show up as placeholder rows rather than table-level loading and don’t update loadingState$. Manual loadRange calls publish viewport events for subscribers, but the loading slots ignore them.

Replace any slot through components:

function LoadingOverlay({ loadingState }) {
  if (loadingState.refresh.status === 'error') {
    return <div role="status">Refresh failed: {loadingState.refresh.errorMessage}</div>
  }

  return <div role="status">Refreshing rows...</div>
}

;<DataTable components={{ LoadingOverlay }} model={model} />

For external controls, logging, or diagnostics, subscribe to the model’s event channel:

const unsubscribe = model.subscribe((message) => {
  if (message.type === 'event' && message.payload?.kind === 'loading') {
    console.log(message.payload.reason, message.payload.phase)
  }
})

loadingState$ (read via useVirtuosoLoadingState() or directly inside the table) is updated from the same initial, refresh, and end events.