Optimistic Updates and Rollback FULL v1.5+
What a write puts on screen before the server answers, and what happens to it if the answer is no.
Writes covers declaring the destination and calling write(); this page covers the behavior around it.
Applied Now, Confirmed Later
Calling write(item) applies the item to the rows immediately, so nobody waits on a round trip to see their own change.
A keyed item, one whose id matches an existing row, merges into that row field by field, so any field you leave out is untouched.
A key that matches nothing yet appends a new row instead, which is how an order appears before the server has seen it.
While the write is unconfirmed the query marks itself isStale, and lastSync is left alone, since nothing has synced.
Optimistic rows are still rows, so isLoading ends once they are on screen, since isLoading means there is nothing to show and now there is.
A write also supersedes any read already in flight, so a slow refresh that started a moment earlier is discarded rather than allowed to arrive afterward and overwrite what you just wrote.
When the Server Says No
write() returns a promise that rejects on failure, so an ordinary try/catch around it works as you'd expect.
The failure shows up in syncError rather than error, which is reserved for a failed first load, and the rows stay on screen either way, so nothing goes blank.
Rollback only undoes what that specific write is still responsible for. Every write claims the fields it changes. From the moment you call write() until the server answers, that write is responsible for those fields.
Whichever write most recently touched a field is the one whose failure reverts it.
That matters as soon as two writes are in flight on the same row at once.
Tick a checkbox (that write claims the done field), then rename the same row while the first request is still out (a second write claims name).
If the checkbox's request fails, only done reverts.
The rename stays exactly as typed, because the checkbox's write never claimed name.
The rename's write did, and its own answer is still on the way.
A row-level snapshot would have wiped the rename off the screen too, mid-edit, so reverting field by field is what lets the two writes fail independently.
The same mechanism protects a field written twice in a row.
Rename a row, then rename it again before the first request has settled, and the second write now claims name.
If the first, now-outdated request is the one that fails, its rollback skips name entirely, because it is no longer the current claimant, and the second write's still-pending value stays on screen untouched.
A rejected create removes its optimistic row entirely, since there was never a real one to fall back to. A rejected delete restores the row it had removed.
Writes never retry on their own.
Reads retry automatically and writes revert, so the decision to try again, and the write() call that does it, are yours.
Why writes never retry gives the reasoning.
Saving Indicators and Page Unload
Every query exposes pendingWrites, a reactive count of writes that have been dispatched but not yet confirmed or rejected.
Bind it wherever the UI should show that a save is in progress:
<p data-show="$tasks.pendingWrites > 0">Saving…</p>
The count goes up as write() dispatches and comes back down as each write settles, whichever way it settles, so the indicator clears on rejection too, by which point the rollback has already put the rows right.
No framework can protect against the browser cancelling every in-flight request the moment the page reloads or navigates away.
A write that hasn't reached the server yet is simply lost, and because optimistic values are never persisted, the reloaded page shows the last confirmed server truth, which is consistent but missing the unsent change.
For writes that must survive unload, pass keepalive: true to the fetch inside your to function; the browser then finishes the request even after the page is gone (bodies up to 64KB).
To sequence navigation behind a save instead, await write() before navigating, or hold navigation while pendingWrites is above zero.
The Manual Pattern
to is optional.
When the actual write happens somewhere the query cannot own directly, such as a plain HTML form the server processes on submit, a third-party sync engine, or code outside your control, write through your normal actions and then tell the query to catch up:
wildflower.component('product-form', {
state: { draft: {} },
async addProduct() {
await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.draft)
});
wildflower.getQuery('products').invalidate();
}
});
Mutate, then invalidate. The refreshed result comes back through the same pipeline as every other update, with row identity keeping the resulting DOM changes minimal. Nothing appears on screen until the request finishes, since there is no optimistic step here.
Adding Optimism by Hand with patch()
Sometimes waiting for that round trip is too slow for the interaction to feel right, when someone adds an item and expects to see it appear now, with the server confirming a moment later in the background.
write() gives you that automatically.
On the manual pattern above, where you own the transport yourself, patch() is the sanctioned way to add the same optimism:
wildflower.component('order-list', {
state: { draft: '' },
async addItem() {
const q = wildflower.getQuery('orders');
const item = { id: 'tmp-' + Date.now(), name: this.draft, pending: true };
q.patch([item]); // on screen immediately
await fetch('/api/orders', { method: 'POST', body: JSON.stringify(item) });
q.invalidate(); // the server's answer reconciles
}
});
patch() pushes data straight into the query's rows through the same pipeline fetched data uses, so it behaves the way a real sync would.
A row sharing a key with an existing one merges field by field, since a partial patch changes only the fields it names and leaves the rest of the row alone.
A row with a key nobody has seen yet is appended, and a row whose declared deleted field is truthy is removed, which is what makes an optimistic delete a single call.
On a record-shaped query, patching merges straight into the record's own fields instead of a list.
Either way the query marks itself isStale until the next real sync confirms or corrects what you patched in, and lastSync is left untouched, since nothing has actually synced yet.
pending: true and the row template binds a class to it. When the confirming sync returns the server's canonical rows, the temporary id and the pending flag disappear together, and row identity keeps everything else on screen untouched.
If the server rejects a manual write, call invalidate() anyway and the next sync restores the server's truth.
What write() adds on top of this pattern is the automatic, field-level rollback covered above.
patch() gets you the optimistic display without the undo.
Mutation queues, rollback journals, and offline outboxes are left to the application or an extension.
rows, the sync flags) from application code draws a dev warning (WF-950), because the next sync will overwrite whatever you wrote. patch() is the sanctioned form of that write.Every change shows on screen first, temp ids are replaced by server ids on confirm, and an armable reject switch shows field-level rollback live. The rejected write reverts while an overlapping one stays.