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
defineListConfigdesdelistkit/server(no la entrada principal). La entrada principal,/next,/react-routery/react-queryse 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 —RowActionsen 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,resolveListConfigyListSkeletontienen su gemelo en/serverjusto 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. SinListKitProvider+useNextRouterAdaptermanuales. Pasathemeaquí, o ponlo una vez en un<ListKitProvider theme={…}>raíz yNextListViewlo hereda (un provider hereda cualquier prop que no pases).useNextHistoryRouterAdapter(listkit/next) — el adaptador de App Router que escribe conhistory.replaceStateen lugar derouter.replace. Unrouter.replacesobre 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 mantieneuseSearchParamssincronizado 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 aListKitProvider;NextListViewconservauseNextRouterAdapter.loadInitialList(config, searchParams, fetcher)(listkit/server) — envuelvebuildListQuery+ 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; elidpor sí solo es único. - El scope ya vive en el
id(ej.id: `orders-${year}`) → nada que hacer; ya está en la llave. - Un
configreusado entre scopes (un tab por-padre, una sub-lista en una página de detalle) → asignacacheScopeal 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 deinitialData, que se trata como autoritativa al montar — sin flash de datos obsoletos. - O invalida imperativamente desde cualquier lugar:
import { invalidateListCache } from 'listkit'y luegoinvalidateListCache('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 todasHazlo 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,
}
}