URL State & Persistence
Write the table's view state to the URL, so a view can be reloaded, shared as a link and read by the server. Plus what localStorage keeps on top of it.
View State in the URL
Six settings decide which rows someone sees:
- search
- sort
- page
- page size
- filters
- grouping
They live in one object — a view — that you create and the table reads. bindViewToUrl writes that object to the URL as query parameters.
Reloading the page then restores the view, and the address can be sent to someone else, who opens
the same view.
The demo below runs against this page's own address bar. Every key carries the demo_ prefix — the prefix option, which keeps one table's parameters apart from
anything else on the page.
Live — the table and the address bar
Name | Role | Department | Location |
|---|---|---|---|
Emma Wilson | Staff Engineer | Platform | Berlin |
Liam Chen | Product Designer | Design | Hamburg |
Sofia Martinez | Engineering Manager | Platform | Munich |
James Park | Frontend Developer | Product | Remote |
Aisha Patel | Data Scientist | Data | Berlin |
Sort a column, search, or page — then look at the address bar. These are the params this table owns right now:
- none — the table is in its default state, so the URL stays clean
<script>
import { createTableView, Table } from '@urbicon-ui/table';
import { bindViewToUrl } from '@urbicon-ui/sveltekit-utils/url.svelte';
const view = createTableView({ defaults: { pageSize: 5 } });
bindViewToUrl(view, { prefix: 'demo_' });
</script>
<Table
{items}
{columns}
{view}
enableSmartFilter
searchPlaceholder="Search employees…"
/>defaults
defaults is written once and does two jobs: it is the
state the table starts in and what the URL does not repeat. A table sitting in its
default state writes no parameters at all, and a reader who clears search and sort gets a
clean address back. A table that never needs the object itself can skip it — <Table {items} {columns} viewDefaults={{ pageSize: 25 }} /> owns its view.
What gets written
Writes are debounced (300 ms) and replace the current history entry, so a burst of sort clicks
does not flood the back button. replaceState: false pushes instead, and axes binds fewer than all six settings.
Defaults, URL, Storage
Three places can supply a setting: the defaults you passed, the URL, and — with the storage binding from the next section — what the reader left behind last time. Every setting is resolved on its own, in phases:
- The defaults.
createTableView({ defaults })is the state the table starts in — during server rendering too. - The URL, on arrival and on every navigation. A parameter that is present takes its setting — synchronously at initialisation, so it also resolves while the server renders. At runtime the URL is the only layer that still applies.
- Storage, once, after hydration. It fills the settings the arriving URL does not name; from then on it only writes. A setting is stored when its last change came from the reader — following someone else's link stores nothing.
Which setting travels under which name:
| Setting | In the URL | In storage |
|---|---|---|
| search | q | yes |
| sort | sort + dir | yes |
| page | page | never |
| page size | size | yes |
| filters | filter, one per filter | yes |
| grouping | group | yes |
| column visibility, column order, summaries | never | prefs, its own entries |
A virtualized table refuses grouping, from the URL like from every other route — a link must not switch a large table into a mode that renders every item. The refusal is the table's decision, not the reader's, so the binding cleans the parameter while a grouping the reader chose earlier survives in storage. DEV warns when it drops one.
The back button restores the default
Navigating to an address without?sort returns the table to its default sort, not
to the stored one — storage applies once, after hydration, and never again. The binding keeps
reading the URL on every navigation, which it has to: SvelteKit does not remount a page for a
query-string change.An empty value is a value
A URL saying?sort= means "unsorted", ?filter= "no filters". The
markers only appear where the default is not empty — otherwise empty is the default and
nothing is written — so a setting the reader cleared stays cleared across a reload.Two URL bindings need distinct prefixes
Two bindings claiming the same URL key throw at registration — a programming error, not a precedence question. A URL binding and a storage binding on the same setting compose; that pairing is the next section.Keeping It Between Visits
While the URL carries the state, the URL is the state. What that does not survive is opening the page from a bare link — nothing was stored, so the reader starts clean. Business tables usually want the opposite, since people expect yesterday's filters to still be there. The second binding is one more line on the same object.
bindViewToStorage lives in @urbicon-ui/table, not in the SvelteKit utilities: web
storage is a browser API, not a SvelteKit one.
Two bindings, one view
<script>
import { bindViewToStorage, createTableView, Table } from '@urbicon-ui/table';
import { bindViewToUrl } from '@urbicon-ui/sveltekit-utils/url.svelte';
const view = createTableView({ defaults: { pageSize: 25 } });
bindViewToUrl(view);
bindViewToStorage(view, { key: 'invoices' });
</script>
<Table {items} {columns} {view} prefs={{ storage: 'invoices' }} />What is stored
Five of the six settings, by default: search, sort, page size, filters and grouping. page is deliberately out — page 1 on arrival is standard UX — while pageSize is in, because "yesterday's page size is still
set" is squarely what persistence promises. The URL keeps carrying both, so a shared link
still names its page. Narrow the set with axes, or hand
in sessionStorage via storage; see Customization.
Only values the reader chose are written: a default nobody touched is never stored. Change pageSize from 25 to 50 in a later release and everyone
who never picked a size gets 50; the readers who did keep theirs. The whole view is one entry
per key — pick a stable, unique one per table, since two
tables sharing a key overwrite each other. A table upgraded from v7 starts from its defaults
once: the old entry per setting is not read (prefs keeps
its own keys).
clear and flush
The binding hands back { clear, flush }. clear() is the "reset saved view" button: it empties
the entry and leaves the live view alone. flush() forces a pending write out before a programmatic
navigation — the teardown drops a write still inside the debounce window rather than letting an
unmounted table write.
The prefs channel
Column visibility, column order and summaries are not view settings. They are
presentation rather than selection, so they travel in prefs and never enter the URL — nobody wants to share a link that hides columns on the other end, and
the server renders every column. prefs={{ storage: 'invoices', persistSelection: true }} takes the selection along.
What the Server Renders
The URL reaches the view synchronously, during initialisation — no effect involved. $effect never runs while the server renders, so view
state applied through one is missing from the server's HTML, and the client replaces the table
on hydration with the reader watching. Applied at initialisation, a ?sort=salary&dir=desc link is already sorted in the markup
that arrives.
The same applies to localStorage as the only home for view
state. The server cannot read it, so a persisted sort would produce one row order on the server
and another after hydration. Storage is a post-hydration phase for exactly that reason: it applies
from an effect, the two renders agree, and yesterday's view arrives a moment later. The URL is visible
to both sides.
These docs are prerendered, with one HTML file per route, built before any query string
exists. SvelteKit forbids reading url.searchParams there, so the binding skips its initialisation read while building and the client applies the real URL instead — the
demo above does its work after hydration. An app that renders per request puts the linked view into
the first response with the same wiring.
With processing: 'server' the load function reads the
same parameters. searchParamsToViewSnapshot resolves them against the
same defaults object the component hands createTableView, and returns the very shape a managed query receives — an absent parameter resolves on the server
exactly as it does in the view, from one declaration rather than two.
Seeding the first fetch
// view-defaults.ts — shared with the component
export const invoiceView = { pageSize: 25 };
// +page.server.ts
import { searchParamsToViewSnapshot } from '@urbicon-ui/sveltekit-utils/table-view';
import { invoiceView } from './view-defaults';
export const load = async ({ url }) => {
const query = searchParamsToViewSnapshot(url.searchParams, invoiceView);
return { initialResult: await fetchInvoices(query) };
};The serializers live in @urbicon-ui/sveltekit-utils/table-view and work without SvelteKit. bindViewToUrl in /url.svelte is the reactive half that needs it. Server Processing covers the fetch side.