Skip to content

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:

StateURL keyFormatDefault omitted
pageIndexpage1-based integerYes (page=1)
pageSizepageSizeintegerYes (pageSize=10)
globalFilterqURL-encoded stringYes (empty)
sortingsortfield:asc or field:desc, comma-joinedYes (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 existing onClick (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: true in the useServerSideTable config
  • status URL param for memberStatus (written on change, read on mount)
  • hrefResolver on the edit action in MiembroTable/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:

  1. Usuario navega a página 3, tamaño 100 en el listado React
  2. Hace click en Editar → el componente llama saveListState + setReturningFlag antes de window.location.href = ...
  3. PHP form carga → usuario edita → PHP redirige de vuelta al listado
  4. El listado React monta → useServerSideTable lee el returning-flag en el init síncrono → restaura página 3 y tamaño 100

Claves en sessionStorage:

  • Estado: bautista:list:v1:{route} (donde route se 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óduloListadoFormVarianteEstado
mod-membresiasMiembroViewReact (HashRouter)URL (syncWithURL)✅ Implementado
mod-ventasProductoViewPHP ?loc=mvfpSessionStorage (syncWithSessionStorage)✅ Implementado

Otros módulos existentes usan modales inline como form — no aplica el pattern.


Files

FileRole
ts/core/utils/urlTableState.tsSerialización URL (parseTableStateFromURL, buildTableSearchParams)
ts/core/utils/listStateStorage.tsSessionStorage helpers (save, read, clear, setReturningFlag, checkAndClearReturningFlag)
ts/core/hooks/useServerSideTable.tsHook genérico con syncWithURL y syncWithSessionStorage
ts/core/types/crud.types.tsSerializableTableState, syncWithURL, syncWithSessionStorage en config
ts/core/components/DataTable.tsxhrefResolver en DataTableAction; anchor wrapping
ts/mod-membresias/Miembro/hooks/useMiembroTableSSR.tsReferencia Variante A
ts/mod-ventas/components/ProductoTable.tsxReferencia Variante B (guarda estado antes de navegar)