Tutorial: Build a Live List FULL
We will build a product board in 5 steps, with each step adding a new capability. By the end our code will read from a real network endpoint, filter and sort, refresh itself, accept edits, and survive a reload with no loading flash.
Step 1: Your First Query
The board reads a list of products from a real JSON file over the network. wildflower.query() declares it once; data-query renders it. Loading and error states are reactive properties the query already tracks.
<div id="board" data-query="products">
<template>
<div class="row">
<span data-bind="name"></span>
<span class="stock"><span data-bind="stock"></span> in stock</span>
</div>
</template>
</div>
wildflower.query('products', {
from: '/examples/data-query/data/products.json',
key: 'id'
});
fetch() call, try/catch, or isLoading flag to maintain. The query makes the request, and the markup reads its state through the $products shorthand. The two snippets above are the whole idea; the live file also has the loading and error markup, and the styling, none of which changes the point. Open the full example or your browser's view-source to see everything at once.
Step 2: Sort and Filter
Real lists need filtering and sorting. There is no filter syntax for this. A computed property reads the query and derives a view, and data-list renders the computed instead of the raw query.
computed: {
visible() {
const q = wildflower.getQuery('products');
const rows = this.category === 'all'
? q.rows
: q.rows.filter(p => p.category === this.category);
// Copy before you sort, never q.rows.sort(...)
return [...rows].sort((a, b) =>
a[this.sortBy] > b[this.sortBy] ? 1 : -1);
}
}
q.rows is the query's own array, and every other component reading this query shares it. Sorting it in place (q.rows.sort(...)) mutates state a computed is only supposed to read, and corrupts what everyone else sees. Copy first: [...q.rows].sort(...). Development builds warn if a computed mutates state during its own evaluation, so this mistake doesn't pass silently.
fetch() is still the better tool.
Step 3: Keep It Fresh
Add a refresh declaration and the query re-fetches on its own, when the tab regains focus and on a timer otherwise. You do not need to write your own merge logic to protect what the user is doing on screen. A refresh replaces the query's rows, and only the query's rows. Your component's own state, such as the category filter and the sort order, stays in your component untouched.
wildflower.query('products', {
from: () => server.list(), // a real endpoint works the same way
key: 'id',
refresh: ['focus', 30] // re-fetch on tab focus, and every 30s otherwise
});
Step 4: Make It Editable
Add a to: destination and the query can write, not just read. write() puts the change on screen immediately. The framework doesn't wait for the server to agree before you see it. If the server rejects, exactly the fields you wrote roll back; nothing else moves.
wildflower.query('products', {
from: () => server.list(),
key: 'id',
refresh: ['focus', 30],
to: item => server.restock(item) // the same server, now handling writes too
});
// In the component:
restock(event, element, details) {
const item = details.item;
wildflower.getQuery('products').write({ id: item.id, stock: item.stock + 10 });
}
to with data applies JSON Merge Patch (RFC 7396) rules. A field present with a value applies. A field explicitly null deletes it. A field the response simply doesn't mention is left alone. The mock server above only echoes back { id, stock }, and name/category survive with no extra work. To remove a field, the server must resolve it to null explicitly.
restock, updating its own row so a later refresh reflects the write instead of contradicting it. from and to can point anywhere that returns a promise; a real backend replaces both without changing anything else here. Click "+10" and watch the number change.
Step 5: Skip the Loading Flash
One more line: persist: true. The board's last confirmed rows are kept in localStorage and painted before any request leaves the machine on the next visit. The query then revalidates in the background, same as always.
You could instead use a store plus a manual localStorage.setItem call in a watch or lifecycle hook. That works, but it is a second system to keep in sync with the first, and it does not know about in-flight writes the way the query does. The query is the same primitive you've been using since step 1, and persist: is one more option on it.
wildflower.query('products', {
from: () => server.list(), // unchanged from step 4
key: 'id',
refresh: ['focus', 30],
to: item => server.restock(item), // unchanged from step 4
persist: true // the only new line
});
isStale resolving. Only confirmed server truth is ever saved; an optimistic write in flight is never what gets written to disk.
clearPersisted().
What You Built
One query ended up reading, refining, refreshing, writing, and persisting, with no hand-rolled fetch(), manual loading flags, or bespoke localStorage cache. Every step's full file is one click away above if you want to copy it as a starting point.
The same pieces at full size. Two clients read one table, writes settle field by field, and persisted rows paint both panels instantly on reload.
Where to go deeper:
- Sources & Refinement: dependent queries, URL templates, request headers
- Freshness & Live Data: the full refresh ladder, SSE, persistence
- Writes & Optimistic Updates: named operations, deletes, creating rows
- Paged & Infinite Results: numbered pages and infinite scroll
- SSR with Data Queries: server-rendered first paint, adopted by the query
- Query Shapes: list, record, and accumulating queries