Appearance
List State Persistence
Module: Core
Type: Technical
Status: Implemented
Description: Opt-in URL persistence of table state (pagination, filter, sort) and anchor-wrapped row actions for native new-tab gestures.
Features
A. URL State Persistence (syncWithURL)
When syncWithURL: true is passed to useServerSideTable, the hook reads pagination, sort, and global filter from URL search params on mount and writes them back on every change.
ts
const tableState = useServerSideTable({
serviceFn: MyService.getAll,
queryKey: myQueryKeys,
syncWithURL: true, // enable URL sync
initialState: {
pageSize: 10,
sorting: [{ id: 'id', desc: false }],
},
});URL keys:
| State | URL key | Format | Default omitted |
|---|---|---|---|
pageIndex | page | 1-based integer | Yes (page=1) |
pageSize | pageSize | integer | Yes (pageSize=10) |
globalFilter | q | URL-encoded string | Yes (empty) |
sorting | sort | field:asc or field:desc, comma-joined | Yes (empty) |
Result: clean URLs — #/bases/miembros instead of #/bases/miembros?page=1&pageSize=10.
Back navigation: the hook uses window.history.replaceState (no Router context required) so URL params survive across renders. Navigating list → detail → Back restores the list state.
Modules without syncWithURL: unchanged — no URL params are read or written.
B. isRestoringFromURL guard
The hook uses a useRef guard to prevent the "reset pageIndex to 0 on filter change" effect from firing during URL restoration at mount time.
- On mount with URL state: page stays at the restored value.
- After mount: user-driven filter/sort changes reset page to 0 as normal.
C. Module-Specific URL Params
Module hooks can persist additional params (e.g. memberStatus) using the same useSearchParams instance:
ts
const [searchParams, setSearchParams] = useSearchParams();
const [memberStatus, setMemberStatus] = useState<MemberStatus>(
(searchParams.get('status') as MemberStatus | null) ?? 'active'
);
useEffect(() => {
const next = new URLSearchParams(searchParams);
if (memberStatus === 'active') next.delete('status');
else next.set('status', memberStatus);
setSearchParams(next, { replace: true });
}, [memberStatus]);B. Anchor-Wrapped Row Actions (hrefResolver)
Adding hrefResolver to a DataTableAction wraps the action button in an <a> element. This enables native browser gestures:
- Left-click: calls
e.preventDefault()then the existingonClick(SPA navigation unchanged) - Middle-click / Ctrl+click: follows the
href, opening a new tab
ts
const actions: DataTableAction<Miembro>[] = [
{
type: 'edit',
onClick: (m) => navigate(`/bases/miembros/${m.id}/editar`),
hrefResolver: (m) => `#/bases/miembros/${m.id}/editar`,
},
];Actions without hrefResolver render as before — no behavioral change.
Reference Implementation: useMiembroTableSSR
useMiembroTableSSR is the first module to adopt both features:
syncWithURL: truein theuseServerSideTableconfigstatusURL param formemberStatus(written on change, read on mount)hrefResolveron the edit action inMiembroTable/index.tsx
Use it as the canonical example when migrating other list→detail modules to URL state persistence.
C. SessionStorage Persistence (syncWithSessionStorage)
Para módulos donde la lista es React pero el form es PHP/Vanilla JS (full page reload), syncWithURL no es suficiente porque la navegación destruye el estado. Se usa syncWithSessionStorage: true junto con el returning-flag pattern.
ts
const tableState = useServerSideTable({
serviceFn: ProductoService.getAll,
queryKey: productoQueryKeys,
syncWithSessionStorage: true, // habilita sessionStorage fallback
initialState: { pageIndex: 0, pageSize: 10 },
});Flujo:
- Usuario navega a página 3, tamaño 100 en el listado React
- Hace click en Editar → el componente llama
saveListState+setReturningFlagantes dewindow.location.href = ... - PHP form carga → usuario edita → PHP redirige de vuelta al listado
- El listado React monta →
useServerSideTablelee el returning-flag en el init síncrono → restaura página 3 y tamaño 100
Claves en sessionStorage:
- Estado:
bautista:list:v1:{route}(donderoutese deriva del param?loc=de la URL) - Flag:
bautista:list:returning:v1:{route}
TTL: 30 minutos. Entradas expiradas se descartan silenciosamente.
Visita fresca (sin flag): cualquier estado anterior se limpia — la lista arranca desde cero.
⚠️ El guardado de estado debe hacerse en el componente tabla (no en el view padre), ya que el hook SSR vive allí y tiene el estado real.
Referencia: ts/mod-ventas/components/ProductoTable.tsx — guarda estado antes de llamar onEdit.
Estado de adopción
| Módulo | Listado | Form | Variante | Estado |
|---|---|---|---|---|
mod-membresias | MiembroView | React (HashRouter) | URL (syncWithURL) | ✅ Implementado |
mod-ventas | ProductoView | PHP ?loc=mvfp | SessionStorage (syncWithSessionStorage) | ✅ Implementado |
Otros módulos existentes usan modales inline como form — no aplica el pattern.
Files
| File | Role |
|---|---|
ts/core/utils/urlTableState.ts | Serialización URL (parseTableStateFromURL, buildTableSearchParams) |
ts/core/utils/listStateStorage.ts | SessionStorage helpers (save, read, clear, setReturningFlag, checkAndClearReturningFlag) |
ts/core/hooks/useServerSideTable.ts | Hook genérico con syncWithURL y syncWithSessionStorage |
ts/core/types/crud.types.ts | SerializableTableState, syncWithURL, syncWithSessionStorage en config |
ts/core/components/DataTable.tsx | hrefResolver en DataTableAction; anchor wrapping |
ts/mod-membresias/Miembro/hooks/useMiembroTableSSR.ts | Referencia Variante A |
ts/mod-ventas/components/ProductoTable.tsx | Referencia Variante B (guarda estado antes de navegar) |