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.

Sandbox Examples: every example on this page runs in an isolated iframe, so it can't interfere with this documentation site's own state. Each is also a complete, self-contained file. Open it directly to read every line, or use it as a starting point for your own app.

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'
});
Live Example A read-only product list Open Full Example ↗
There is no 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.
Read more: Query Shapes covers the record shape, scalars, and the rest of the state surface this step only touched.

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);
    }
}
Copy before you sort. 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.
Live Example Category filter and sort over the same query Open Full Example ↗
The query itself didn't change. It still just fetches. Filtering and sorting are ordinary derived state, the same way you'd derive anything else from a reactive value.
Read more: Sources and Refinement covers dependent queries, function sources, and when plain 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
});
Live Example A refresh that leaves your filter and sort alone Open Full Example ↗
This example's simulated server nudges every quantity up or down a few counts on each fetch, the way real inventory drifts between two people checking a warehouse. Set the category to Perennial, sort by quantity, then click "Simulate a background refresh." The numbers actually change. Your category and sort don't move.
Read more: Freshness and Live Data covers the full refresh ladder, conditional requests, and SSE push.

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 });
}
Confirmations are safe by default. A write's optimistic apply is a field merge, so unnamed fields survive untouched. Its confirmation merges the same way. Resolving 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.
Live Example Optimistic writes with a simulated server Open Full Example ↗
Nothing genuinely server-side is reachable from this page. The same simulated server from step 3 now also handles 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.
Read more: Writes and Optimistic Updates covers named operations, deletes, and creating new rows.

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
});
Live Example The finished board, persisted across reloads Open Full Example ↗
Open the full example in its own tab and reload it. The list is there before the network request finishes, showing last visit's quantities, then jumps to the server's fresh numbers a moment later. That jump is isStale resolving. Only confirmed server truth is ever saved; an optimistic write in flight is never what gets written to disk.
Read more: Freshness and Live Data covers the expiry window and 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.

Full Shared Favorites

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: