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, pageSize, filters and groupBy. They live in one object, the view, which you
create and the table reads. bindViewToUrl writes that object to the URL as query parameters,
so reloading restores the view and the address opens the same view for whoever you send it to.
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 from the toolbar, search, or turn the 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…"
/>Call bindViewToUrl at the top level of your component's <script>, next to the view it binds: it reads the
URL right there, synchronously, which is what puts a shared link's sort into the
server-rendered HTML. The binding tears itself down with the component, so there is no handle
to keep and nothing to unsubscribe.
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. Every setting takes one: createTableView({ defaults: { pageSize: 25, sort: { column: 'date', direction: 'desc' } } }). viewDefaults={{ pageSize: 25 }} sets the same defaults
on a table that owns its view; that table hands you no view object, so a URL binding always creates
one.
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: ['search', 'sort'] binds fewer than all six settings
(the setting names above, not the URL keys below).
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 |
pageSize | size | yes |
filters | filter=column:operator:value, one per filter | yes |
groupBy | 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 happens in the rendering alone — the parameter stays in the address bar, a grouping the reader chose earlier survives in storage, and an un-virtualized table on the same view still groups. DEV warns when a grouping goes unrendered.
The back button restores the default
Navigating to an address without?sort returns the table to its default sort rather
than to the stored one: storage applies once, after hydration, and never again. The binding keeps
reading the URL on every navigation, so back and forward work without a remount.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 (where empty is the default, 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, so a second bound table on the page needs aprefix. 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. Open the page from a bare link and the reader starts clean, because nothing was stored. 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, pageSize, filters and groupBy. page is deliberately out, since 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 the same axes: ['search', 'sort'] option, 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, so pick a stable, unique one per table: two
tables sharing a key overwrite each other. Reusing the string from prefs is a naming convention rather than a link, which
is why the example above says 'invoices' twice: the two channels keep their own entries,
and persisting both always takes both statements. A table upgraded from v7 starts from its defaults
once, since the old per-setting entries are not read.
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: unmounting drops a write still inside the debounce window instead of letting a dead
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, so a ?sort=salary&dir=desc link is already sorted in the
markup that arrives. View state applied from an $effect would not be: effects never run while the server
renders, and the reader would watch the table rearrange itself on hydration.
The server cannot read localStorage, so a persisted
sort would put one row order in the server's HTML and another after hydration. That is why
storage is a post-hydration phase: the two renders agree, and yesterday's view arrives a
moment later. The URL is the layer both sides can see.
A prerendered route has no query string to read at build time, so the binding renders the defaults and the client applies the real URL at initialisation. That is what these docs do, and the demo above starts working on hydration. A route rendered per request puts the linked view into the first response instead.
When the backend does the sorting and paging (source={{ processing: 'server' }}), your load reads the same parameters. searchParamsToViewSnapshot resolves them against the
same defaults object the component hands createTableView, so an absent parameter resolves on the
server exactly as it does in the view, from one declaration rather than two. What it returns
is what a query function receives.
Seeding the first fetch
// view-defaults.ts — one declaration, both sides
export const invoiceView = { pageSize: 25 };
// +page.svelte
import { invoiceView } from './view-defaults';
const view = createTableView({ defaults: invoiceView });
// +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.