Persistence ($db)
$db treats localStorage as first-class reactive state. Declare a $db descriptor on a state property and the runtime hydrates from disk on mount, writes back on every mutation, and broadcasts changes to other tabs, for free.
Quick example
<!-- user-prefs.lego -->
<script>
export default {
theme: $db('prefs.theme').default('light'),
volume: $db('prefs.volume').default(50).debounce(300)
}
</script>
<template>
<h1>Theme: [[ theme ]]</h1>
<input type="range" b-sync="volume">
</template>Drag the slider, the in-memory value updates instantly. The localStorage write is debounced 300 ms, so dragging won't hammer disk. Open the same URL in another tab, both tabs sync.
How it works
When the proxy spots a $db descriptor as a property value at mount time, it:
- Reads
localStorage[key]. If present, parses it as JSON; otherwise usesdefault(v). - Replaces the descriptor with the resolved value, so the property behaves like any other reactive value.
- Records the binding so future writes flow through
scheduleSave(with optional debounce). - Subscribes the binding to a single shared
storagelistener for cross-tab sync.
A re-entrancy guard prevents the obvious feedback loop: an incoming cross-tab value is applied without re-firing a save.
Initialization API (fluent)
$db('key') // descriptor
.default(initial) // value to use if storage is empty
.debounce(ms) // delay between mutation and disk writeBoth methods are optional and chainable; both return the descriptor.
{
cart: $db('cart').default([]),
search: $db('search').default('').debounce(250),
size: $db('size').default('M')
}Namespace your keys
localStorage is shared by every script on the origin. Use prefixes like myapp.theme, not bare theme. The runtime's quota-eviction logic only evicts keys this app wrote, but reading bare keys can still collide with third-party storage.
Runtime API
You can use the descriptor directly without binding it to state:
$db('user').set({ name: 'Alice' }); // immediate write
$db('user').set({ name: 'Bob' }, 200); // debounced write
const u = $db('user').get(); // synchronous read
$db('user').delete(); // remove + notify bindingsset and delete notify every block currently bound to that key, in addition to writing to disk.
In JS contexts outside a block, use Lego.db (or Lego.globals.$db):
import { Lego } from 'lego-dom';
Lego.db('auth.token').set(token);Debouncing high-frequency writes
localStorage is synchronous and blocking. Hammering it during a scroll, drag, or every-keystroke binding will cause UI jank on slower devices. Debounce inputs that mutate often:
{
draft: $db('post.draft').default('').debounce(500)
}The first mutation arms a timer. Each subsequent mutation within the window pushes the deadline back. The user pauses → the save fires.
Cross-tab sync
Bindings to the same key in different tabs stay in sync via the browser's storage event:
- Open the app in tab A and tab B.
- Change a
$dbvalue in tab A. - Tab B re-renders with the new value.
A single storage listener handles every key. There's no per-binding overhead.
Quota and eviction
Browsers cap localStorage (typically ~5 MB). When a write exceeds the quota:
- The runtime catches
QuotaExceededError. - It evicts the oldest keys this app wrote (FIFO) until 2× the needed space is free.
- The write is retried.
If retry still fails, Lego.config.onError fires with type: 'quota-critical'. Eviction never touches keys this app didn't write, third-party localStorage usage is left alone.
Patterns
Auth token
{
token: $db('auth.token').default(null),
async login(creds) {
const { token } = await fetch('/login', { method: 'POST', body: JSON.stringify(creds) }).then(r => r.json());
this.token = token; // saved to disk + broadcast across tabs
},
logout() {
this.token = null; // saved as null
}
}Form draft auto-save
{
draft: $db('post.draft').default('').debounce(500),
publish() {
fetch('/posts', { method: 'POST', body: this.draft });
this.draft = ''; // clears in-memory and on disk
}
}Computed reads
{
user: $db('user').default(null),
get displayName() {
return this.user?.name ?? 'Guest';
}
}The getter reads this.user, the runtime tracks that read, so any cross-tab update of the user re-runs the binding that displayed displayName.
Use in .lego script vs b-logic
Both work. $db is exposed as a top-level identifier in both contexts.
<!-- script -->
<script>
export default {
theme: $db('theme').default('light')
}
</script><!-- template-level b-logic -->
<template b-id="user-prefs" b-logic="{ theme: $db('theme').default('light') }">…</template><!-- instance-level b-logic -->
<user-prefs b-logic="{ theme: $db('user-99.theme').default('light') }"></user-prefs>