SSR and caching

Hydrate from server-rendered data, cut Next.js boilerplate, and use the zero-dependency cache or TanStack Query.

Server-side rendering (initialData)

By default the list fetches on the client: the server renders an empty/loading shell and rows appear after hydration. For SEO, a faster first paint, and no loading flash, fetch the first page on the server and hand it to <ListView> as initialData — it renders those rows in the initial HTML and skips the client's first fetch. Paging and filtering afterwards still run on the client.

The catch: the server must compute the same query the client will derive from the URL, or the two renders disagree and React warns about a hydration mismatch. buildListQuery (from listkit/server) does exactly that — use its result both to fetch and as initialQuery:

// app/orders/page.tsx — a React Server Component
import { buildListQuery } from 'listkit/server'
import { ordersConfig } from './config'
import { listOrders } from './actions'
import { OrdersList } from './OrdersList'

export default async function OrdersPage({
	searchParams,
}: {
	searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
	const query = buildListQuery(ordersConfig, await searchParams)
	const initial = await listOrders(query) // { data, total }
	return <OrdersList initialData={initial} initialQuery={query} />
}

Since the config is now read in a Server Component, build it with defineListConfig from listkit/server (not the main entry). The main entry, /next, /react-router and /react-query ship with a 'use client' banner: everything they export is a client reference, so a shared config module the server evaluates may still import a component from the main entry — RowActions in a card renderer, say — and render it as JSX. What it may not do is call a function from there during the RSC render; defineListConfig, resolveListConfig and ListSkeleton have /server twins for exactly that. Both the server page and the client list view can import the same config module when it's defined this way.

// config.ts — shared by the server page and the client list view
import { defineListConfig } from 'listkit/server'
export const ordersConfig = defineListConfig<Order>({
	/* … */
})
// OrdersList.tsx — a Client Component
'use client'
import { ListView, serverActionAdapter } from 'listkit'
import type { ListQuery, ListResult } from 'listkit'
import { ordersConfig } from './config'
import { listOrders } from './actions'

export function OrdersList({
	initialData,
	initialQuery,
}: {
	initialData: ListResult<Order>
	initialQuery: ListQuery
}) {
	const adapter = serverActionAdapter<Order>(q => listOrders(q))
	return (
		<ListView
			config={ordersConfig}
			adapter={adapter}
			initialData={initialData} // rendered in the server HTML
			initialQuery={initialQuery} // used only while the URL still matches
		/>
	)
}

The same server action (listOrders) powers both the server's first page and the client's later fetches — no duplicated fetching logic. initialData is used only while the live query equals initialQuery; the moment the user changes a page/filter (or calls useListRefresh()), the list fetches normally. It's fully opt-in: lists without initialData keep client-fetching unchanged.

Less boilerplate (Next.js)

Three helpers cover the wiring every SSR/Next app would otherwise hand-roll:

  • NextListView (listkit/next) — <ListView> pre-wired with the App Router adapter, so search/page/filters/sort sync to the URL. No manual ListKitProvider + useNextRouterAdapter. Pass theme here, or set it once on a root <ListKitProvider theme={…}> and NextListView inherits it (a provider inherits any prop you don't pass).
  • useNextHistoryRouterAdapter (listkit/next) — the App Router adapter that writes through history.replaceState instead of router.replace. A router.replace onto the current page is a same-page navigation, and Next refetches the page's RSC segment for those — every filter, search keystroke or page change re-renders the server page and remounts the list (skeleton flash, lost scroll). Next keeps useSearchParams in sync with the History API, so the list still re-queries through its adapter; only the server round-trip goes. Also the adapter for a list mounted under a URL that carries more than its own params (a detail overlay's /items/{id}), where a router navigation would render that route on top of the live list. Pass it to ListKitProvider; NextListView keeps useNextRouterAdapter.
  • loadInitialList(config, searchParams, fetcher) (listkit/server) — wraps buildListQuery + the first-page fetch and degrades to a client fetch on error. Returns { initialData, initialQuery }.
  • ListSkeleton — a ready-made <Suspense> fallback (toolbar bar + skeleton table) for the streaming SSR pattern. Import it from listkit/server in a Server Component (the page), or from listkit in client code.
// app/orders/page.tsx — Server Component
import { Suspense } from 'react'
// Import both from /server in RSC — the main barrel is a client boundary.
import { ListSkeleton, loadInitialList } from 'listkit/server'
import { ordersConfig } from './config'
import { listOrders } from './actions'
import { OrdersList } from './OrdersList'

export default function OrdersPage({ searchParams }) {
	return (
		<Suspense fallback={<ListSkeleton />}>
			<OrdersData searchParams={searchParams} />
		</Suspense>
	)
}

async function OrdersData({ searchParams }) {
	const { initialData, initialQuery } = await loadInitialList(
		ordersConfig,
		await searchParams,
		listOrders
	)
	return <OrdersList initialData={initialData} initialQuery={initialQuery} />
}
// OrdersList.tsx — Client Component
'use client'
import { NextListView } from 'listkit/next'
import { serverActionAdapter } from 'listkit'
import { ordersConfig } from './config'
import { listOrders } from './actions'

export function OrdersList({ initialData, initialQuery }) {
	const adapter = serverActionAdapter(q => listOrders(q))
	return (
		<NextListView
			theme='blue'
			config={ordersConfig}
			adapter={adapter}
			initialData={initialData}
			initialQuery={initialQuery}
		/>
	)
}

Built-in cache (zero dependencies)

By default useListData keeps the last response in memory for 30 seconds (staleTime). This means:

  • Going back to a page you already visited shows data instantly — no loading flash.
  • If the cache is stale, the old data is shown immediately while a background refresh runs (stale-while-revalidate).
  • Identical in-flight requests are deduplicated so rapid filter changes don't fire duplicate calls.
  • Calling useListRefresh() invalidates this list's cached pages and refetches (see Refreshing after a mutation); invalidateListCache(id?) does the same imperatively from anywhere.
  • The cache is bounded (least-recently-used eviction, ~100 entries shared across all lists), so a long-running app can't grow it without limit. If you need a larger, GC-tunable, cross-component cache, inject TanStack Query (below) and let it own the lifecycle.

You can tune or disable it per list:

// Cache responses for 5 minutes
<ListView config={config} adapter={adapter} staleTime={5 * 60 * 1000} />

// Disable cache (always fetch)
<ListView config={config} adapter={adapter} staleTime={0} />

The list id identifies the dataset, not the view

The cache keys every response on config.id + the query (page, pageSize, search, filters, sort). So the id must uniquely identify which dataset the list shows. Any scope that changes the rows but isn't part of the query — a studentId or customerId the adapter closes over, a parent record the list hangs off — is invisible to the cache.

When one config is mounted in several such scopes, they collide: visit the list under scope A, then scope B with the same query within staleTime, and listkit serves A's cached rows to B without hitting the server. It's intermittent by nature — it only bites when a matching entry is still warm.

Pass the scope as cacheScope and listkit folds it into the cache id (`${config.id}::${cacheScope}`) so each view gets its own bucket — no config cloning, no id mutation:

// One planeacionesConfig, one instance per student — no cross-student bleed.
<ListView
	config={planeacionesConfig}
	adapter={adapter}
	cacheScope={studentId}
/>

Rules of thumb:

  • Rendered once, globally (e.g. an admin /users page) → nothing to do; the id alone is unique.
  • The scope already lives in the id (e.g. id: `orders-${year}`) → nothing to do; it's already in the key.
  • One config reused across scopes (a per-parent tab, a detail-page sub-list) → set cacheScope to the scope value.

invalidateListCache(config.id) still clears every scope of that id (it matches on the id:: prefix), so a mutation that affects all scopes refreshes them all; useListRefresh() inside a scoped view refreshes only that view. In development, listkit console.warns when it sees the same resolved id mounted on more than one route — the signature of a missing cacheScope.

Refreshing after a mutation

With an async adapter, listkit fetches on the client, so a server mutation won't show until the query changes. Call useListRefresh() from any descendant of <ListView> (a row's delete button, a modal) to force a refetch — no full page reload. It's a no-op outside a ListView, so shared buttons stay safe:

import { useListRefresh } from 'listkit'

function DeleteButton({ onConfirm }) {
	const refresh = useListRefresh()
	return (
		<button
			onClick={async () => {
				await onConfirm() // server action
				refresh() // row disappears immediately
			}}
		>
			Delete
		</button>
	)
}

refresh() truly invalidates this list's cached pages (it doesn't just bump a token), so the refetched data also wins on a later remount — a deleted row can't reappear when you navigate away and back.

For mutations that happen outside the list tree (e.g. a separate create/edit page), you have two options:

  • Call revalidatePath(...) in the server action. On return, the server re-renders and hands <ListView> a fresh initialData seed, which is treated as authoritative on mount — no stale flash.
  • Or invalidate imperatively from anywhere: import { invalidateListCache } from 'listkit' then invalidateListCache('your-config-id') (omit the id to clear all lists, e.g. on sign-out).

In-memory lists (the data prop) refresh automatically when data changes — this is only needed for async adapters.

Using with TanStack Query

If your app already uses TanStack Query and you want its cross-component cache, background refetch, retries, and devtools, back your lists with React Query instead of the built-in cache. Import the ready-made hook from listkit/react-query — no need to hand-roll one:

import { ListView } from 'listkit'
import { useReactQueryListData, invalidateList } from 'listkit/react-query'

// A QueryClientProvider must sit above the list.
;<ListView
	config={customersConfig}
	adapter={customersAdapter}
	useListData={useReactQueryListData}
/>

The hook keys each page by the list's config.id + query, honors the staleTime listkit passes, keeps the current rows visible while the next page loads (keepPreviousData), and uses an SSR seed as initialData when present.

@tanstack/react-query is an optional peer dependency — install it only if you use this module.

Refresh after a mutation. useListRefresh() works as usual (it bumps a token that's part of the query key). For mutations that run outside the list tree, call invalidateList(queryClient, listId) — the React Query counterpart to invalidateListCache:

await deleteCustomer(id)
invalidateList(queryClient, 'customers') // refetch this list; omit the id for all

Roll your own. Prefer full control over the query options? Inject any UseListDataHook — when you pass useListData, listkit delegates every fetch to it and never touches the built-in Map cache:

import { useQuery } from '@tanstack/react-query'
import type { UseListDataHook } from 'listkit'

const useCachedListData: UseListDataHook<Customer> = (
	adapter,
	query,
	refreshToken
) => {
	const { data, isLoading, error } = useQuery({
		queryKey: ['customers', 'list', query, refreshToken],
		queryFn: () => adapter.fetch(query),
		staleTime: 5 * 60 * 1000,
	})
	return {
		data: data?.data ?? [],
		total: data?.total ?? 0,
		isLoading,
		error,
	}
}

On this page