SSR y caché

Hidrata desde datos renderizados en el servidor, reduce el boilerplate de Next.js y usa la caché sin dependencias o TanStack Query.

Renderizado en servidor (initialData)

Por defecto la lista fetchea en el cliente: el servidor renderiza un shell vacío/cargando y las filas aparecen después de la hidratación. Para SEO, una primera pintura más rápida y sin flash de carga, obten la primera página en el servidor y pásala a <ListView> como initialData — renderiza esas filas en el HTML inicial y omite el primer fetch del cliente. La paginación y filtrado posterior siguen ejecutándose en el cliente.

El problema: el servidor debe calcular la misma query que el cliente derivará de la URL, o ambos renders no coincidirán y React advertirá sobre un hydration mismatch. buildListQuery (desde listkit/server) hace exactamente eso — usa su resultado tanto para fetchear como para initialQuery:

// app/orders/page.tsx — un 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} />
}

Dado que la config ahora se lee en un Server Component, constrúyela con defineListConfig desde listkit/server (no la entrada principal). La entrada principal, /next, /react-router y /react-query se publican con un banner 'use client': todo lo que exportan es una client reference, así que un módulo de config compartido que el servidor evalúa sí puede importar un componente de la entrada principal — RowActions en el render de una card, por ejemplo — y renderizarlo como JSX. Lo que no puede es llamar una función de ahí durante el render de RSC; defineListConfig, resolveListConfig y ListSkeleton tienen su gemelo en /server justo para eso. Tanto la página del servidor como la vista de lista del cliente pueden importar el mismo módulo de config cuando se define de esta manera.

// config.ts — compartido por la página del servidor y la vista de lista del cliente
import { defineListConfig } from 'listkit/server'
export const ordersConfig = defineListConfig<Order>({
	/* … */
})
// OrdersList.tsx — un 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} // renderizado en el HTML del servidor
			initialQuery={initialQuery} // usado solo mientras la URL aún coincide
		/>
	)
}

La misma server action (listOrders) alimenta tanto la primera página del servidor como los fetches posteriores del cliente — sin lógica de fetching duplicada. initialData se usa solo mientras la query en vivo sea igual a initialQuery; en el momento en que el usuario cambia de página/filtro (o llama useListRefresh()), la lista fetchea normalmente. Es totalmente opt-in: las listas sin initialData siguen fetcheando desde el cliente sin cambios.

Menos boilerplate (Next.js)

Tres helpers cubren el cableado que toda app SSR/Next debería hacer manualmente:

  • NextListView (listkit/next) — <ListView> pre-cableado con el adaptador de App Router, así que búsqueda/página/filtros/sort se sincronizan con la URL. Sin ListKitProvider + useNextRouterAdapter manuales. Pasa theme aquí, o ponlo una vez en un <ListKitProvider theme={…}> raíz y NextListView lo hereda (un provider hereda cualquier prop que no pases).
  • useNextHistoryRouterAdapter (listkit/next) — el adaptador de App Router que escribe con history.replaceState en lugar de router.replace. Un router.replace sobre la página actual es una navegación same-page, y Next vuelve a pedir el segmento RSC de la página en cada una — cada filtro, tecla de búsqueda o cambio de página re-renderiza la página del servidor y remonta la lista (flash de skeleton, scroll perdido). Next mantiene useSearchParams sincronizado con la History API, así que la lista sigue consultando por su adaptador; solo desaparece el viaje al servidor. También es el adaptador para una lista montada bajo una URL que carga más que sus propios params (el /items/{id} de un overlay de detalle), donde una navegación del router renderizaría esa ruta encima de la lista viva. Pásalo a ListKitProvider; NextListView conserva useNextRouterAdapter.
  • loadInitialList(config, searchParams, fetcher) (listkit/server) — envuelve buildListQuery + el fetch de primera página y degrada a un fetch del cliente en caso de error. Retorna { initialData, initialQuery }.
  • ListSkeleton (listkit) — un fallback de <Suspense> listo para usar (barra de toolbar + tabla esqueleto) para el patrón de SSR streaming.
// app/orders/page.tsx — Server Component
import { Suspense } from 'react'
import { loadInitialList } from 'listkit/server'
import { ListSkeleton } from 'listkit'
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}
		/>
	)
}

Caché integrada (cero dependencias)

Por defecto useListData mantiene la última respuesta en memoria durante 30 segundos (staleTime). Esto significa:

  • Volver a una página que ya visitaste muestra los datos al instante — sin flash de carga.
  • Si la caché está obsoleta, los datos antiguos se muestran inmediatamente mientras un refresh silencioso corre en segundo plano (stale-while-revalidate).
  • Las peticiones idénticas en vuelo se deduplican así que cambios rápidos de filtros no disparan llamadas duplicadas.
  • Llamar useListRefresh() invalida las páginas en caché de esta lista y refetchea (ver Refrescar después de una mutación); invalidateListCache(id?) hace lo mismo imperativamente desde cualquier lugar.
  • La caché es acotada (evicción LRU, ~100 entradas compartidas entre todas las listas), así que una app de larga duración no puede crecerla sin límite. Si necesitas una caché más grande, con recolección de basura ajustable y entre componentes, inyecta TanStack Query (abajo) y deja que sea suyo el ciclo de vida.

Puedes ajustar o desactivar la caché por lista:

// Cachear respuestas por 5 minutos
<ListView config={config} adapter={adapter} staleTime={5 * 60 * 1000} />

// Desactivar caché (siempre fetchear)
<ListView config={config} adapter={adapter} staleTime={0} />

El id de la lista identifica al dataset, no a la vista

La caché indexa cada respuesta por config.id + la query (page, pageSize, search, filters, sort). Por eso el id debe identificar de forma única qué dataset muestra la lista. Cualquier scope que cambie las filas pero no forme parte de la query — un studentId o customerId que el adapter captura en su closure, un registro padre del que cuelga la lista — es invisible para la caché.

Cuando un mismo config se monta en varios de esos scopes, colisionan: entras a la lista bajo el scope A, luego al scope B con la misma query dentro de staleTime, y listkit sirve las filas cacheadas de A a B sin llamar al server. Es intermitente por naturaleza — solo pasa si hay una entrada aún fresca que coincide.

Pasa el scope como cacheScope y listkit lo integra al id de caché (`${config.id}::${cacheScope}`) para que cada vista tenga su propio bucket — sin clonar el config ni mutar su id:

// Un solo planeacionesConfig, una instancia por estudiante — sin fuga entre estudiantes.
<ListView
	config={planeacionesConfig}
	adapter={adapter}
	cacheScope={studentId}
/>

Reglas prácticas:

  • Se renderiza una vez, global (ej. una página admin /users) → nada que hacer; el id por sí solo es único.
  • El scope ya vive en el id (ej. id: `orders-${year}`) → nada que hacer; ya está en la llave.
  • Un config reusado entre scopes (un tab por-padre, una sub-lista en una página de detalle) → asigna cacheScope al valor del scope.

invalidateListCache(config.id) sigue limpiando todos los scopes de ese id (hace match por el prefijo id::), así que una mutación que afecta a todos los scopes los refresca a todos; useListRefresh() dentro de una vista scopeada refresca solo esa vista. En desarrollo, listkit emite un console.warn cuando detecta el mismo id resuelto montado en más de una ruta — la firma de un cacheScope faltante.

Refrescar después de una mutación

Con un adaptador asíncrono, listkit fetchea en el cliente, así que una mutación en el servidor no se verá hasta que cambie la query. Llama useListRefresh() desde cualquier descendiente de <ListView> (el botón eliminar de una fila, un modal) para forzar un refetch — sin recargar la página. Es un no-op fuera de un ListView, así que los botones compartidos siguen siendo seguros:

import { useListRefresh } from 'listkit'

function DeleteButton({ onConfirm }) {
	const refresh = useListRefresh()
	return (
		<button
			onClick={async () => {
				await onConfirm() // server action
				refresh() // la fila desaparece inmediatamente
			}}
		>
			Eliminar
		</button>
	)
}

refresh() invalida realmente las páginas en caché de esta lista (no solo incrementa un token), así que los datos refetcheados también ganan en un remount posterior — una fila eliminada no puede reaparecer cuando navegas fuera y vuelves.

Para mutaciones que ocurren fuera del árbol de la lista (ej. una página separada de crear/editar), tienes dos opciones:

  • Llama revalidatePath(...) en la server action. Al regresar, el servidor re-renderiza y entrega a <ListView> una semilla fresca de initialData, que se trata como autoritativa al montar — sin flash de datos obsoletos.
  • O invalida imperativamente desde cualquier lugar: import { invalidateListCache } from 'listkit' y luego invalidateListCache('tu-config-id') (omite el id para limpiar todas las listas, ej. al cerrar sesión).

Las listas en memoria (prop data) se refrescan automáticamente cuando data cambia — esto solo es necesario para adaptadores asíncronos.

Uso con TanStack Query

Si tu app ya usa TanStack Query y quieres su caché entre componentes, refetch en segundo plano, reintentos y devtools, respalda tus listas con React Query en lugar de la caché integrada. Importa el hook ya hecho desde listkit/react-query — no hace falta escribir uno a mano:

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

// Debe haber un QueryClientProvider por encima de la lista.
;<ListView
	config={customersConfig}
	adapter={customersAdapter}
	useListData={useReactQueryListData}
/>

El hook indexa cada página por el config.id de la lista + la query, respeta el staleTime que listkit le pasa, mantiene las filas actuales mientras carga la siguiente página (keepPreviousData) y usa un seed de SSR como initialData si existe.

@tanstack/react-query es una peer dependency opcional — instálala solo si usas este módulo.

Refrescar tras una mutación. useListRefresh() funciona igual (incrementa un token que forma parte de la query key). Para mutaciones fuera del árbol de la lista, llama invalidateList(queryClient, listId) — el equivalente en React Query de invalidateListCache:

await deleteCustomer(id)
invalidateList(queryClient, 'customers') // refetch de esta lista; omite el id para todas

Hazlo tú mismo. ¿Prefieres control total sobre las opciones de la query? Inyecta cualquier UseListDataHook — cuando pasas useListData, listkit delega cada fetch a tu hook y nunca toca la caché Map integrada:

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