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:

  1. Column widths
  2. Column order
  3. Hidden columns
  4. Frozen columns
  5. Sort state
  6. Group state
  7. Filter model
  8. Free-text search term

Each domain has:

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:

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.

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":

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:

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:

This avoids collisions between different grid instances and routes.

If an explicit key option is provided, it is used as-is:


4. Persisted Settings and Formats

4.1 Column Widths

Control options:

In-memory structure:

Persisted JSON format (object map):

{
  "id": 60,
  "name": 220,
  "email": 280
}

Rules:

Write path:

Read path:


4.2 Column Order

Control options:

In-memory structure:

Persisted JSON format (ordered array):

["id", "name", "email", "weight"]

Rules:

Write path:

Read path:

Special behavior:


4.3 Hidden Columns

Control options:

In-memory structure:

Persisted JSON format (array):

["weight", "email"]

Normalization rules:

Write path:

Read path:


4.4 Frozen Columns

Control options:

In-memory structure:

Persisted JSON format (array):

["id", "name"]

Normalization rules:

Write path:

Read path:


4.5 Free-text Search Term

Control options:

The search term lives on the attached DataManager (not the grid), so it is parked at load and applied after the DataManager is available:

Persisted JSON format (a JSON-encoded string):

"alice"

Rules:


5. Error Handling and Environment Guards

All persistence methods are defensive:

Example warning categories:

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:

  1. hidden keys are pruned against current valid columns
  2. hidden keys are persisted
  3. visible columns are recomputed
  4. saved order is applied
  5. 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

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:

  1. Pending debounced writes in the persistence write queue are cancelled (timers cleared) so a stale value cannot land after the clear.
  2. The four cached _cachedHasPersisted{Widths,Order,Hidden,Frozen} flags are reset to false.
  3. The four resolved storage keys (filtered by their enabled flags) are passed to provider.clear(keys). If the provider does not expose clear the persistence layer falls back to per-key removeItem.
  4. In-memory state is wiped: savedColumnWidths Map cleared, savedColumnOrderKeys emptied, _hiddenColumnKeys and _frozenColumnKeys Sets 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.
  5. The declared grouping and sort are re-parked: _declarativeGroupColumns / _declarativeSortColumns (retained at construction, not consumed by the first setColumns()) are copied back into _pendingGroupColumns / _pendingSortColumns, so step 6 re-applies them.
  6. The grid re-applies the declarative columns via setColumns(this._allColumns excluding the selection column), which re-seeds hidden/frozen from the original hidden/frozen props because the in-memory caches are now empty, and applies the re-parked grouping/sort.
  7. If row data is already loaded, renderVisibleRows(true) is called so the user sees the initial layout immediately.
  8. 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:

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