4. Persistence & forms
Right now your tasks vanish on refresh. That's about to be a one-line fix.
LegoDOM's persistence layer is called $db. It 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. No imports. No glue. No setup.
Persist the task list
Open src/task-list.lego and change one line:
// before
tasks: [
{ title: 'Learn the .lego format', done: true },
{ title: 'Build a reactive list', done: false },
{ title: 'Wire up routing', done: false }
],
// after
tasks: $db('tasks.all').default([
{ title: 'Learn the .lego format', done: true },
{ title: 'Build a reactive list', done: false },
{ title: 'Wire up routing', done: false }
]),Save. Refresh the page, add a task, refresh again. Your task is still there.
Now open the same URL in a second tab. Same list. Check a box in one tab and watch the other tab update in real time, with no code from you.
That's $db. The descriptor knows it's persisted; the proxy hydrates from localStorage on mount; mutations flow through the proxy's set trap and write back; cross-tab updates ride the browser's storage event. You did one line of work and got durability and synchronization.
What just happened
When the proxy assigns the descriptor:
- It reads
localStorage['tasks.all']. If present, parses the JSON; if absent, uses the default. - Replaces the descriptor with the resolved value, so the property behaves like any other reactive property from then on.
- Records the binding so future writes flow through
scheduleSave(with optional debounce). - Subscribes to a single shared
storagelistener for cross-tab sync.
A re-entrancy guard prevents the obvious feedback loop: incoming cross-tab values would otherwise trigger a write, which would fire another storage event in the originating tab, which would trigger another update, and so on.
Debouncing noisy writes
For state that mutates rapidly (a draft, a search input, a slider), debounce so you're not pinging localStorage on every keystroke:
draft: $db('tasks.draft').default('').debounce(300),The first write schedules a save in 300 ms. Subsequent writes within the window push the deadline back. The user pauses, the save fires.
Form patterns worth naming
You've already used b-sync. A few modifiers and conventions are worth pinning down before you start collecting them ad hoc.
b-sync modifiers
<input b-sync.trim="draft"> <!-- strip whitespace on write -->
<input b-sync.lazy="search"> <!-- update on `change` instead of `input` -->
<input type="number" b-sync.number="age"> <!-- coerce to Number on write -->
<input b-sync.lazy.number="quantity"> <!-- modifiers stack -->.lazy is the one you'll reach for most. Search inputs that hit the network on every keystroke are worse than search inputs that wait for the user to pause.
Submit handling
A <form b-action> automatically calls event.preventDefault() for you. So this is the canonical submit pattern:
<form b-action @submit="add()">
<input b-sync.trim="draft">
<button>Add</button>
</form>You can also do <form @submit.prevent="add()"> (which is what the original task list used). Pick whichever reads better at the call site; both work.
Live search
Let's actually build something with these. Add a search box to task-list.lego:
<input b-sync.trim="filter" placeholder="Filter…">Then in the script:
filter: $db('tasks.filter').default(''),
get visibleTasks() {
return this.tasks.filter(t =>
t.title.toLowerCase().includes(this.filter.toLowerCase())
);
}…and replace the b-for="task in tasks" with b-for="task in visibleTasks".
Type in the search box. The list filters as you type. Refresh the page. Your search query is still there. Open another tab. Same query, same filtered list.
This is the part that should feel weird if you're coming from a framework where filtered lists, persistent state, and cross-tab sync are three separate libraries. Here it's three properties: a filter, a $db descriptor, and a getter. The runtime did the rest.
Quota and eviction
localStorage has a hard quota (browsers cap it around 5 MB). When a write exceeds it:
- The runtime catches the
QuotaExceededError. - Evicts the oldest tracked keys (FIFO) to free 2× the space the new value needs.
- Retries the write.
If eviction can't free enough room, Lego.config.onError is invoked with type: 'quota-critical' and the value is dropped. Eviction only touches keys this app wrote; third-party localStorage usage is left alone.
Direct $db from JS
Anywhere you have access to Lego (or via $db inside a block), you can read and write directly:
const tasks = Lego.db('tasks.all').get(); // synchronous read
Lego.db('tasks.all').set(tasks); // immediate write
Lego.db('tasks.all').delete(); // remove + notify bindingsYou'll use these in the next chapter when we add login state.