Settings Persistence Implementation in Vanilla-Grid
This document describes how Vanilla-Grid persists user settings, which settings are stored, how keys are generated, and the exact JSON formats used.
The grid does not access window.localStorage directly: it goes through a pluggable storage provider (see §1.1) so the same code path serves browser, SSR, test, and remote-backed environments.
1. Overview
Vanilla-Grid persists eight settings domains:
- Column widths
- Column order
- Hidden columns
- Frozen columns
- Sort state
- Group state
- Filter model
- Free-text search term
Each domain has:
- an enable/disable option (
persistence.<domain>.enabled) - an optional explicit storage key override (
persistence.<domain>.storageKey) - fallback key generation logic when no override is provided
The first seven domains are enabled by default (enabled !== false). The search
term is the exception: it defaults to off (enabled === true to opt in),
because a free-text search is transient session state rather than a durable
layout choice. Enable it explicitly with persistence.searchTerm.enabled: true.
Load occurs during grid construction, before initial rendering, for synchronous storage modes. For asynchronous modes (storage-mode="remote"), the load completes after await grid.ready() resolves and the grid then re-applies persisted layout (see §1.3).
1.1 Storage modes
The grid exposes a single storageMode property (also as the storage-mode HTML attribute on <vn-grid>). Supported values:
| Mode | Behaviour |
|---|---|
none |
Default. Persistence is disabled. Nothing is read or written. Opt-in is required to enable persistence. |
local |
Wraps window.localStorage; falls back to an in-memory Map when unavailable (private mode/SSR). |
remote |
Uses an application-supplied async provider (see §1.2). Reads are awaited; writes are queued in background. |
<vn-grid id="peopleGrid" storage-mode="local"></vn-grid>
<vn-grid id="settingsGrid" storage-mode="none"></vn-grid>
<vn-grid id="cloudGrid" storage-mode="remote"></vn-grid>
Programmatic equivalent:
gridElement.initializeGrid({ storageMode: 'remote', storageProvider: myProvider });
1.2 Storage provider interface
Built-in providers live in src/vanilla-grid/storage/grid-storage-provider.js and are exposed on window for non-bundled use:
VanillaGridStorageProvider— abstract baseVanillaGridNullStorageProvider— no-op (used by modenone)VanillaGridLocalStorageProvider— wrapswindow.localStorage(used by modelocal)VanillaGridRemoteStorageProvider— async base class to subclass for moderemote
Provider contract:
class VanillaGridStorageProvider {
isAsync = false; // true → operations may resolve out-of-tick
getItem(key) // -> string | null (or Promise of)
setItem(key, value) // -> void (or Promise of)
removeItem(key) // -> void (or Promise of)
hasItem(key) // -> boolean (or Promise of)
clear(keys) // -> void (or Promise of)
}
clear(keys) is invoked by grid.clearPersistedSettings() (see §10) with the exact list of resolved storage keys the grid owns. Sync providers may iterate removeItem (the default base implementation does this); the abstract base also collects any returned promises into a single Promise.all so async providers Just Work.
VanillaGridLocalStorageProviderinherits the default per-key implementation — no override needed.VanillaGridNullStorageProvideroverrides it as a no-op.VanillaGridRemoteStorageProviderdefines it as an interface method that throws by default. Subclasses MUST override it (typically with a single transactionalDELETEagainst a settings endpoint, or a per-key delete loop) so that backend state stays in sync with the cleared in-memory state.
A typical remote provider only needs to override getItem/setItem/removeItem/clear:
class MyApiStorageProvider extends VanillaGridRemoteStorageProvider {
constructor(baseUrl, token) {
super();
this._baseUrl = baseUrl;
this._token = token;
}
async getItem(key) {
const res = await fetch(`${this._baseUrl}/${encodeURIComponent(key)}`, {
headers: { Authorization: `Bearer ${this._token}` }
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Failed to load ${key}`);
return await res.text();
}
async setItem(key, value) {
const res = await fetch(`${this._baseUrl}/${encodeURIComponent(key)}`, {
method: 'PUT',
headers: {
'Content-Type': 'text/plain',
Authorization: `Bearer ${this._token}`
},
body: value
});
if (!res.ok) throw new Error(`Failed to save ${key}`);
}
async removeItem(key) {
await fetch(`${this._baseUrl}/${encodeURIComponent(key)}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${this._token}` }
});
}
async clear(keys) {
// Recommended: a single transactional endpoint. Per-key deletes also work.
await Promise.all(keys.map((key) => this.removeItem(key)));
}
}
// Wire it up before initializeGrid():
gridElement.setStorageProvider(new MyApiStorageProvider('/api/grid-settings', token));
gridElement.setAttribute('storage-mode', 'remote');
gridElement.initializeGrid({ /* ... */ });
await gridElement.ready(); // wait for the remote round-trip
await gridElement.loadRowsAsync(); // safe to load data now
1.3 Async timing & background writes
For storage-mode="remote":
- Reads: the grid synchronously constructs itself with empty persistence state, then asynchronously loads all four settings in parallel via the provider.
grid.ready()(orgridElement.ready()) returns a Promise that resolves once the load completes; the element fires avn-grid-persistence-readyevent after re-applying the persisted layout. - Writes: every persist call is fire-and-forget. The persistence feature coalesces rapid writes per-key with a configurable debounce window (default 60 ms, see
persistence.writeDebounceMs), then serializes them per-key so a slow remote save never blocks the UI nor races against a newer save for the same key. Failures are logged via the configuredlogger.warn. Callawait grid.flushPersistence()to await all queued writes (e.g. before navigation).
For storage-mode="local" or "none" the timing is fully synchronous and identical to previous behaviour — grid.ready() resolves on the next microtask and no host-code changes are required.
1.4 Legacy storage adapter — removed
The legacy options.storage parameter ({ getItem, setItem, removeItem, hasItem? })
is no longer accepted (May 2026). It was an undocumented holdover
with no internal callers, no tests, and no docs example. Hosts that need a custom
back-end should pass an options.storageProvider (synchronous or asynchronous)
instead — see the storage provider contract in
src/vanilla-grid/storage/grid-storage-provider.js.
1.5 Write-debounce and flush policy
Three options under persistence control how and when queued remote writes are flushed:
| Option | Type | Default | Description |
|---|---|---|---|
persistence.writeDebounceMs |
number |
60 |
Milliseconds to wait before flushing a pending write. Rapid changes to the same key within this window are coalesced into a single write. Set to 0 to fire on the next event-loop turn (no coalescing). |
persistence.flushOnVisibilityHidden |
boolean |
false |
When true, attaches a visibilitychange listener that calls flushPendingWrites() when the page becomes hidden (document.visibilityState === 'hidden'). This is the modern "save-on-leave" strategy. Only active for async (remote) providers. |
persistence.flushOnBeforeUnload |
boolean |
false |
When true, attaches a beforeunload listener that calls flushPendingWrites(). Note: modern browsers do not guarantee async work will complete in beforeunload; prefer flushOnVisibilityHidden for reliable save-on-leave behaviour. Only active for async providers. |
Both listeners are removed automatically when grid.destroy() (or gridElement.disconnectedCallback()) is called.
Example — fast writes + automatic save-on-leave:
const grid = new VanillaGrid({
storageMode: 'remote',
storageProvider: myRemoteProvider,
persistence: {
writeDebounceMs: 250, // coalesce up to 250 ms of rapid changes
flushOnVisibilityHidden: true, // save when tab is hidden/switched away
flushOnBeforeUnload: false, // off — browser may ignore async work here
},
});
2. Load Sequence During Initialization
At initialization time, Vanilla-Grid resolves all four storage keys and then bulk-loads persisted state via persistence.loadAll(). The result populates:
savedColumnWidths(Map)savedColumnOrderKeys(Array)_hiddenColumnKeys(Set)_frozenColumnKeys(Set)
For sync providers this completes before setColumns(...) runs — order/visibility/freeze logic sees the persisted state immediately. For async providers the state is populated after grid.ready() resolves, and the element re-applies the layout automatically.
3. Key Generation Strategy
All default keys follow this pattern:
vanilla-grid:{setting-domain}:{pathname}:{scope}
Where:
setting-domainis one of:column-widthscolumn-orderhidden-columnsfrozen-columnssort-stategroup-statefilter-modelsearch-term
pathnameiswindow.location.pathname(or'app'if unavailable)scopeis first available of:gridIdoption (set automatically from the host element'sidwhen using the<vanilla-grid>web component, otherwise fromoptions.gridId)bodyTable.idviewport.idheaderTable.id'default'(a warning is logged once when this fallback is used, since it causes settings collisions between multiple grids on the same page)
This avoids collisions between different grid instances and routes.
If an explicit key option is provided, it is used as-is:
persistence.columnWidths.storageKeypersistence.columnOrder.storageKeypersistence.hiddenColumns.storageKeypersistence.frozenColumns.storageKey
4. Persisted Settings and Formats
4.1 Column Widths
Control options:
persistence.columnWidths.enabled(defaulttrue)persistence.columnWidths.storageKey(optional)
In-memory structure:
savedColumnWidths: Map<string, number>
Persisted JSON format (object map):
{
"id": 60,
"name": 220,
"email": 280
}
Rules:
- Only finite positive numeric widths are saved.
- Keys must be non-empty strings.
Write path:
saveColumnWidths()builds/updates map from header<colgroup>widthspersistColumnWidthsToStorage()serializes map to JSON object
Read path:
loadColumnWidthsFromStorage()parses object entries intosavedColumnWidthsapplySavedColumnWidths()applies widths by current column key matching
4.2 Column Order
Control options:
persistence.columnOrder.enabled(defaulttrue)persistence.columnOrder.storageKey(optional)
In-memory structure:
savedColumnOrderKeys: string[]
Persisted JSON format (ordered array):
["id", "name", "email", "weight"]
Rules:
- Only non-empty string keys are saved.
- Order reflects current visible
columnsarray order.
Write path:
persistColumnOrderToStorage()stores ordered key array
Read path:
loadColumnOrderFromStorage()parses and sanitizes arrayapplySavedColumnOrder()rebuildscolumnsby key map
Special behavior:
- Selection checkbox column (
__vgSelection__) is always pinned first when present. - Unknown/missing keys are ignored; unmatched columns are appended in their existing order.
4.3 Hidden Columns
Control options:
persistence.hiddenColumns.enabled(defaulttrue)persistence.hiddenColumns.storageKey(optional)
In-memory structure:
_hiddenColumnKeys: Set<string>
Persisted JSON format (array):
["weight", "email"]
Normalization rules:
- Keys are normalized via
_normalizeColumnKey(trim + lowercase) - Selection column key is excluded
Write path:
persistHiddenColumnsToStorage()serializes set values to array
Read path:
loadHiddenColumnsFromStorage()parses array, normalizes, filters invalid/selection keys
4.4 Frozen Columns
Control options:
persistence.frozenColumns.enabled(defaulttrue)persistence.frozenColumns.storageKey(optional)
In-memory structure:
_frozenColumnKeys: Set<string>
Persisted JSON format (array):
["id", "name"]
Normalization rules:
- Keys are normalized via
_normalizeColumnKey(trim + lowercase) - Selection column key is excluded
Write path:
persistFrozenColumnsToStorage()serializes set values to array
Read path:
loadFrozenColumnsFromStorage()parses array, normalizes, filters invalid/selection keys
4.5 Free-text Search Term
Control options:
persistence.searchTerm.enabled(defaultfalse— opt-in)persistence.searchTerm.storageKey(optional)
The search term lives on the attached DataManager (not the grid), so it is parked at load and applied after the DataManager is available:
- Write:
<vn-grid>.search(term)sets the term on the manager, then callspersistSearchTermToStorage(). - Read:
loadAll()parses the stored value into the returnedsearchTermstring; the grid parks it as_pendingSearchTermand<vn-grid>applies it on the first data load (viagetPendingSearchTerm()), composing with any restored filter model in a single reload.
Persisted JSON format (a JSON-encoded string):
"alice"
Rules:
- Only a non-empty term is parked for restore (an empty term means "no search").
clearPersistedSettings()wipes the key, the parked term, and the manager's active term.
5. Error Handling and Environment Guards
All persistence methods are defensive:
- Exit early when persistence feature is disabled
- Exit early when running without browser/localStorage access (
typeof window === 'undefined'or missingwindow.localStorage) - Wrap parse/write operations in
try/catch - Log warnings on failure without breaking runtime behavior
Example warning categories:
- failed to persist widths/order/hidden/frozen columns
- failed to load widths/order/hidden/frozen columns
If load fails, structures are reset to safe defaults ([] or new Set()).
6. Interactions with Runtime Features
6.1 setColumns(...)
After new columns are provided:
- hidden keys are pruned against current valid columns
- hidden keys are persisted
- visible columns are recomputed
- saved order is applied
- frozen grouping is re-applied
This keeps storage state coherent even when backend/schema changes.
6.2 Reordering
reorderColumn(...) calls persistColumnOrderToStorage() after mutation, so drag/drop reorder is immediately persisted.
6.3 Resizing
stopResize() calls saveColumnWidths(), which persists widths at drag end.
6.4 Visibility and Freeze operations
hideColumn(...)/showColumn(...)/showAllColumns()persist hidden statefreezeColumn(...)/unfreezeColumn(...)/unfreezeAll()persist frozen state
6.5 clearPersistedSettings()
Wipes every grid-owned settings key from the active provider AND resets the corresponding in-memory state, then re-applies declarative columns so the grid returns to its initial layout.
Available on both VanillaGrid and <vn-grid>:
// VanillaGrid (sync provider → undefined; async provider → Promise<void>)
grid.clearPersistedSettings();
// <vn-grid> (always returns Promise<void> for a consistent contract)
await gridElement.clearPersistedSettings();
What happens, in order:
- Pending debounced writes in the persistence write queue are cancelled (timers cleared) so a stale value cannot land after the clear.
- The four cached
_cachedHasPersisted{Widths,Order,Hidden,Frozen}flags are reset tofalse. - The four resolved storage keys (filtered by their
enabledflags) are passed toprovider.clear(keys). If the provider does not exposeclearthe persistence layer falls back to per-keyremoveItem. - In-memory state is wiped:
savedColumnWidthsMap cleared,savedColumnOrderKeysemptied,_hiddenColumnKeysand_frozenColumnKeysSets cleared, the active sort is cleared via_sorting.clearSort(), the active grouping is cleared via_grouping.clearGrouping(), and the first-data autofit gate is re-armed. - The declared grouping and sort are re-parked:
_declarativeGroupColumns/_declarativeSortColumns(retained at construction, not consumed by the firstsetColumns()) are copied back into_pendingGroupColumns/_pendingSortColumns, so step 6 re-applies them. - The grid re-applies the declarative columns via
setColumns(this._allColumns excluding the selection column), which re-seeds hidden/frozen from the originalhidden/frozenprops because the in-memory caches are now empty, and applies the re-parked grouping/sort. - If row data is already loaded,
renderVisibleRows(true)is called so the user sees the initial layout immediately. - The web component then reloads — but only when the reset changed the DataManager's query. See "The trailing reload" below.
"Reset" therefore means the same thing for every facet: back to the declaration. Since a user's persisted grouping/sort choice outlives the declarative default (§6.6), this call is the only way back to a declared group-by / sort-by.
The web-component proxy always returns a Promise and dispatches vn-grid-persistence-cleared (bubbles: true) on completion (or vn-grid-error with phase: 'persistence-clear' on failure).
The trailing reload
<vn-grid>.clearPersistedSettings() does more than proxy: after the grid's own reset it clears the DataManager's column filters and search term, fires vn-grid-persistence-cleared, and may call reload().
That reload exists to serve exactly three states, and nothing else:
| state | why the query changed |
|---|---|
an active column filter — dm.getColumnFilters() is non-empty |
the grid is holding a filtered subset the cleared state no longer describes |
an active search term — dm.getSearchTerm() is a non-empty string |
same |
server-side sorting — grid.serverSort |
clearing the sort is a query change only the server can answer |
When none of them holds, the DataManager's query is identical before and after, so a reload re-delivers the very rows the grid already has — and makes it re-order a million of them to arrive where it already was. _resetRequiresReload() in vanilla-grid-element.js reads all three off the public DataManager surface every built-in manager implements, before the reset clears them, and the reload is skipped when the answer is no.
vn-grid-persistence-cleared fires in both branches, and clearPersistedSettings() returns a Promise in both.
Infinite scroll is deliberately not a fourth trigger. With infiniteScroll enabled, skipping the reload keeps the pages the user has already scrolled into instead of snapping back to page one. Reset is a layout reset, not a data reset — that is the reading § 6.5 has always documented, and it is the one the code now follows.
deferToReload — when a reload IS coming
VanillaGrid#clearPersistedSettings({ deferToReload: true }) is what <vn-grid> passes when it has decided a reload follows. It mirrors the option of the same name on clearSort(), and it changes steps 4 and 6 above:
clearSort({ deferToReload: true })clears the sort state and the header indicators without reordering;clearGrouping({ deferToReload: true })clears the group state without reordering (see Row Grouping § 6.1);setColumns(declarative, { parkSortAndGroupState: true })leaves the re-parked declarative sort and grouping parked instead of applying them.
setRows() then consumes that parked state — establishing it, and only it — and its own reorder produces the ordering. One reorder for the whole gesture, on the shared shimmer/Worker bracket (Sorting § 7.2.1), rather than four superseded ones plus a fifth in-thread reorder inside the reload.
The key→index resolution both call sites need (and its two warnings, "names column X, which does not match any column" and "which is not sortable") lives in one place: VanillaGrid#_consumeParkedSortAndGroupState().
Measured, samples/grid-minimal-js/?rows=1000000, headless Chromium, total main thread blocked across the reset gesture:
| scenario | before | after |
|---|---|---|
| ungroup every level, then reset | 4053 ms | 657 ms |
| reset straight from the grouped default | 4308 ms | 721 ms |
What remains is a single buildRenderEntries() scan (~570 ms), which runs inside the shimmer and is tracked separately.
Typical use case: a "Reset grid layout" toolbar button in the host application — exactly the pattern shown in people-cities-js and northwind-orders-js.
6.6 Declarative state is a default; the user's persisted choice wins
Several facets can be declared up front — hidden / frozen on a column
definition, grouping.columns / the group-by attribute, sorting.columns /
the sort-by attribute. All of them mean the same thing: how the grid opens
until the user decides otherwise. Once the user changes that facet, their
persisted choice takes priority on every later visit.
The subtlety is what "the user decided" looks like in storage. Removing the
last group level persists []; clearing the sort persists []; hiding nothing
persists an empty set. Those are states, not absences — and they must beat
the declarative default, or the grid would undo the user's choice on every
reload. So the question asked at load is "does a key exist for this
domain?", never "is the loaded value non-empty":
// _loadPersistedState()
this._pendingSortEntries = (this._persistence.hasPersistedSort() && Array.isArray(state.sort))
? state.sort : null;
this._pendingPersistedGroupState = (this._persistence.hasPersistedGroup() && Array.isArray(state.group))
? state.group : null;
this._pendingPersistedGroupAggregates = (this._persistence.hasPersistedGroup() && Array.isArray(state.groupAggregates))
? state.groupAggregates : null;
// setColumns(), hidden/frozen seeding
const wantHidden = !this._hasPersistedKey(this.resolvedHiddenColumnsStorageKey) && col.hidden === true;
_hasPersistedKey() (and the hasPersisted*() flags it reads, cached by
loadAll()) exists precisely to distinguish "first run" from "user cleared
all" — see its own doc comment. An empty persisted array parks as [], which
is truthy, so it wins the precedence || in setColumns() and the grid opens
exactly as the user left it.
Two ways out, for a grid whose grouping or sort is part of what the data means rather than a user preference:
persistence: { groupState: { enabled: false } } // never persist the grouping
persistence: { sortState: { enabled: false } } // never persist the sort
With the domain's persistence disabled — or with storageMode: 'none', the
default — no key is ever written, so the declaration wins on every load.
6.7 The group-state domain holds two things, under one flag
group-state is the one domain whose stored value is an object rather than
an array:
{ "levels": [{ "key": "country", "direction": "asc" }],
"aggregates": [{ "key": "salary", "fn": "sum" }] }
A group aggregate belongs to the grouping session, not to the column — it
is created when grouping becomes active and discarded when the last level goes
(see Grouping Implementation § 14). Giving it
a key and a flag of its own would let the two halves of one thing be persisted
independently, so that a grid could reload holding totals for a grouping it no
longer has. One key, one flag, one lifetime: a grid that opts out with
persistence: { groupState: { enabled: false } } opts out of both.
A value stored before aggregates existed is a bare array of levels, and
reads back as "no aggregates configured". That is one branch at the parse
boundary in _parseGroupState() — no version field, no migration pass and no
write-back:
const blob = Array.isArray(parsed) ? { levels: parsed, aggregates: [] } : parsed;
loadAll() splits the blob into state.group and state.groupAggregates, so
every reader downstream still sees two plain arrays, and getGroupState()'s
public array shape is untouched — the widening is confined to the stored form.
7. Configuration Reference
7.1 Per-domain persistence settings
| Setting Domain | Persist Flag | Override Key Option | JSON Shape |
|---|---|---|---|
| Column widths | persistence.columnWidths.enabled |
persistence.columnWidths.storageKey |
object { key: number } |
| Column order | persistence.columnOrder.enabled |
persistence.columnOrder.storageKey |
string array [] |
| Hidden columns | persistence.hiddenColumns.enabled |
persistence.hiddenColumns.storageKey |
string array [] |
| Frozen columns | persistence.frozenColumns.enabled |
persistence.frozenColumns.storageKey |
string array [] |
| Sort state | persistence.sortState.enabled |
persistence.sortState.storageKey |
array [{ key, direction }] |
| Group state | persistence.groupState.enabled |
persistence.groupState.storageKey |
object { levels: [{ key, direction }], aggregates: [{ key, fn }] } — see § 6.7 |
| Filter model | persistence.filterModel.enabled |
persistence.filterModel.storageKey |
object { key: filter } |
| Search term (default off) | persistence.searchTerm.enabled |
persistence.searchTerm.storageKey |
JSON string "…" |
7.2 Write-debounce / flush policy
| Option | Type | Default | Notes |
|---|---|---|---|
persistence.writeDebounceMs |
number ≥ 0 |
60 |
Debounce window in ms before each key's pending write is executed. Applies to async providers only; sync providers write immediately. |
persistence.flushOnVisibilityHidden |
boolean |
false |
Flush all pending writes when the page becomes hidden. Async providers only. |
persistence.flushOnBeforeUnload |
boolean |
false |
Flush all pending writes on beforeunload. Async providers only. Less reliable than flushOnVisibilityHidden on modern browsers. |
8. Example: Explicit Storage Keys
The 'my-grid:' prefix used below is an arbitrary, host-chosen namespace — pick one that does not clash with other widgets on the page. The grid never assumes any specific prefix.
const HOST_PREFIX = 'my-app:people-grid'; // any unique prefix the host owns
const grid = new VanillaGrid({
persistence: {
columnWidths: { enabled: true, storageKey: `${HOST_PREFIX}:widths:v1` },
columnOrder: { enabled: true, storageKey: `${HOST_PREFIX}:order:v1` },
hiddenColumns: { enabled: true, storageKey: `${HOST_PREFIX}:hidden:v1` },
frozenColumns: { enabled: true, storageKey: `${HOST_PREFIX}:frozen:v1` }
}
});
This is useful when migrating route paths or when you want explicit versioning of persisted settings.
9. Practical Notes
- Widths and order are keyed by column key strings; changing column keys invalidates previous stored data.
- Hidden/frozen keys are normalized to lowercase, so key matching is case-insensitive.
- The persistence layer is non-blocking: failures degrade gracefully to runtime defaults.