Summary#
A component writes data from an action: create a row with new Entity { … }, set fields by assignment, then
call UnitOfWork.Commit() to persist. The mutations apply optimistically — they take effect on the client the instant
the action runs, so the UI updates with no round-trip — and UnitOfWork.Commit() flushes them to the server, which validates
and persists them atomically. If the server rejects the write, the optimistic edit rolls back.
action Save() {
new Note { Title = title }; // create — applied optimistically on the client
UnitOfWork.Commit(); // persist to the server (atomic); on failure, rolls back
}A form is this action plus inputs bound to state: the user types, state updates, the action reads that state into the new row.
Where UnitOfWork.Commit() goes — the one decision#
There are two shapes, and a page is one of them:
| shape | example | where UnitOfWork.Commit() goes |
|---|---|---|
| form — nothing persists until Save | an edit screen with a Save button | once, in the Save action |
| per action — the act IS the save | ticking a to-do, archiving, deleting a row | in each verb |
Both are correct and the compiler does not ask you which one you meant — you choose by asking whether the user should be able to change their mind before anything is stored. If yes — an edit screen, a form with several fields, anything a Cancel makes sense on — the page holds its edits and one Save commits them. If no, commit in the action.
The unit of work is the DEFAULT, and it is the better model whenever the choice is close: a person can add, close
and delete several things and then decide, and UnitOfWork.Discard() throws the pending edits away without leaving
the page. That is the shape admin is built on.
⚠ Do not silence it by removing the
UnitOfWork.Commit()on a page that has no Save. A function called with an entity argument runs inside the calling page's unit of work rather than its own, so its write becomes durable only when that page commits — and if nothing does, it is discarded when the page goes away, with no error and no failed request. The optimistic overlay renders the change, so the screen looks right.osy lintcatches this shape asdata-write-never-committed.A function taking only scalars runs standalone and commits in-band, which is why a
Create(string title)verb persists with noUnitOfWork.Commit()of its own whileToggle(Item i)next to it does not. That difference appears nowhere in the source of either — it is the argument type that decides.
Signature#
new Entity { Field = value, … }; // create a row (optimistic); evaluates to its id
var e = new Entity { … }; // …bind the id to update or reference it
e.Field = value; // update a field (optimistic)
e.Delete(); // delete a row (optimistic) — the row vanishes from the page's queries at once
UnitOfWork.Commit(); // persist all pending edits to the server, atomicallyDeleting a row#
.Delete() on the row. It joins the page's unit of work exactly like an edit does, and lands when Save does —
so the delete action itself has no UnitOfWork.Commit() in it, the same as an "Add item" action does not:
using Osyrin.Ui;
entity Note {
[Required, MaxLength(200)] string Title;
security { allow read, create, update, delete when IsAnonymous || IsAuthenticated; }
}
[Page("/notes")]
[AllowAnonymous]
[Render(CSR)]
component Notes() {
live var notes = Note.ToList();
string draft = "";
action Remove(Note n) { n.Delete(); } // the delete joins the page's unit of work
action Add() { new Note { Title = draft }; draft = ""; }
action Save() { UnitOfWork.Commit(); } // …and lands here, with everything else
render {
Stack {
Field("Title", value: draft);
Button("Add", onPress: Add);
foreach (var n in notes) {
Row { Text(n.Title); Button("Remove", onPress: () => Remove(n)); }
}
Button("Save", onPress: Save);
}
}
}⚠ A per-row action that commits is a different design, and it needs no ceremony. If a page genuinely has no
Save — a list where pressing the bin is the whole interaction — then Remove commits, and that is all there is to
write:
action Remove(Note n) { n.Delete(); UnitOfWork.Commit(); }⛔ What you must NOT do is drop the UnitOfWork.Commit() from that shape. On a page with no Save the write then
sits in a unit of work nothing commits, and is discarded when the page goes away — with no error, no failed request
and a screen that looks exactly right, because the optimistic overlay rendered it. That is the one failure here you
cannot see for yourself, and it is why data-write-never-committed is a MUST rule in osy lint.
It is optimistic like the others: the row leaves the page's queries immediately, before the server has been asked, and comes back if the commit fails. So a list re-renders without it at once and nothing has to be re-fetched.
⚠ There is no Delete on the entity TYPE — no Note.Delete(id). You delete a row you are holding, which is
what a page always has: the foreach variable, or an entity-typed parameter passed to the action. That is the same
rule as updating, where you assign to n.Title rather than calling a setter on Note.
⚑ Deleting several ROWS THE PAGE HOLDS is a loop, and it is still one commit: the unit of work is what makes
them atomic, so all of them land or none does. Deleting by predicate — "every stale order", rows the page never
loaded — is the set-based terminal instead: Order.Where(o => o.Status == "Stale").Delete() runs one statement in
the database, immediately, outside the page's unit of work, and answers how many went. Same split for updates
(.Update(o => { … })) and per-row creates (.Insert(s => new T { … })). See Delete, Update
and Insert — and note the refusal that keeps the two models honest: a bulk verb will not run while
the page's unit of work holds uncommitted changes of the same type, because a statement over stored rows cannot see
them.
Why it hangs off UnitOfWork#
The save is spelled on a receiver — UnitOfWork.Commit(), not a bare commit() — because the receiver is the point.
A page's edits accumulate in one unit of work, and the single most common mistake is not knowing that: writing to
an entity and never committing, or committing in every action because each one looked like a separate save. Naming
the unit of work at every call site puts the thing you are committing in front of you while you write it.
There is no lowercase carve-out to remember: everything you declare and everything you call is PascalCase, and the
compiler says so if it drifts (ACTION_NAME_NOT_PASCAL_CASE). Parameters and component props stay camelCase
(Guid id, tone), as do the platform's own event props (onClick).
Description#
A page's data edits accumulate in an optimistic overlay — one unit of work for the whole page (or tab). Edits from every action land in that same overlay and stay there, visible, until the user decides to save:
new Entity { … }creates a row locally and evaluates to its id. It's visible immediately — to the rest of the action (its fields read back through the overlay), and to the page's ownlive var/foreach(they read through it). Add ten items across ten clicks and all ten show up, before anything is saved.⚠ One read does not see it: a new
Entity.Where(…)issued inside an action. That is a SERVER read — the filter never leaves the server — so it answers over committed data only, and a row you created a line earlier is not in it. The runtime refuses that line rather than handing back a wrong count. Ask the page'slive var, which already holds the rows including the pending ones, orUnitOfWork.Commit()first and then read.e.Field = valuerecords a field change on an existing (or just-created) row; reads reflect it at once.UnitOfWork.Commit()sends everything accumulated so far to the server, which applies your app's validation and security rules and persists it in one atomic step, then confirms it back as settled data.UnitOfWork.Discard()throws that accumulation away instead — the edits roll back and the screen returns to what the server last confirmed. The page stays open; only its pending changes go.
Both read the same in a server function and in a UI action, and mean the same thing: UnitOfWork is the accumulated
work, Commit persists it, Discard drops it.
Where UnitOfWork.Commit() belongs — two places, never more. Committing is a user decision, so put UnitOfWork.Commit() on:
- a dedicated Save action (a Save button), and
- a discard-guard — when the user is about to leave unsaved work (closing a tab/dialog, navigating away).
Do not sprinkle UnitOfWork.Commit() through ordinary actions. An "Add item" action just creates the row; the user's
Save is what persists the batch. This keeps drafts editable and cancellable, and matches how the server already
lets a function read its own uncommitted writes. A commit that fails (validation or permission) reverts the
overlay, so the UI never shows data the server rejected. A create obeys the entity's write permissions — the acting
user must be allowed to create the entity.
Throwing changes away — UnitOfWork.Discard()#
Discarding is the other half of the same decision, and until it existed the only way to abandon edits was to close the thing holding them:
action StartOver() {
UnitOfWork.Discard(); // the page's pending edits are gone; the page itself stays open
}It clears one unit of work — the one you are in — and stops there. That asymmetry with Commit() is deliberate
and it matters: a commit reaches outward, because persisting is the outermost unit of work's job and an inner scope's
edits have to get there. A discard must not, or closing an inner surface would take the surrounding page's unsaved
work with it.
Reads fall back to what the server last confirmed, so a field that was edited shows its stored value again, and a row created only in the overlay is simply no longer there.
Where a create-form's draft belongs#
A draft is an ordinary pending row, so it obeys the rule above: the page's own queries read it back. That is the
feature — it is what makes Add show the new item instantly — and it is also the one way a create form goes wrong.
A component that holds Entity draft = new Entity { }; and queries that same entity has enlisted a row before
anybody has typed. Two things follow, neither visible in the source: the list paints a blank phantom row on first
paint, and the never-filled draft rides the next genuine Commit(), where its unset [Required] fields fail the
save and name a row the user never opened. osy lint reports the pair as ui-draft-field-ghosts-its-own-list
(SHOULD) — it fires on the member-initialiser spelling and on on mount { draft = new Entity { }; } alike, because
both run at mount.
Two ways out, and they are not equivalent:
- Give the draft its own unit of work — put it on a component you open with
Dialog.Open(NewThing(), unitOfWork: Root)(Dialog.Open / Dialog.Ask / Dialog.Confirm / Dialog.Discard). Nothing is pending in the list's unit of work, and the draft stays an entity, so every[Required("…")]sentence is still declared once on the model (Validation). This is the one to reach for. - Hold the fields as local scalars (
string title = "";) and construct the entity inside the action that saves it. Nothing is pending until the action runs — but the fields are no longer entity properties, so a bound control has no rules to configure itself from and every declared message has to be re-typed as a guard in the action. Right when the form does not correspond to one entity; a real cost otherwise.
⚠ The kit Dialog(title, onDismiss) control is not the first option. It is [Composable] markup inlined into
the page's own tree and carries no unit of work at all, so a draft inside it is pending in the page's — exactly the
shape being fixed.
Examples#
A list the user builds up before saving — Add creates a row optimistically (it appears in the list at once, via
foreach reading through the overlay); a separate Save action is the only place that commits:
entity Item { string Title; }
[Page("/items")]
[Render(CSR)]
component ItemList() {
string draft = "";
var items = Item.ToList();
action Add() { new Item { Title = draft }; } // optimistic — shows immediately, not yet saved
action Save() { UnitOfWork.Commit(); } // the user's Save button — persists the whole batch
render {
Stack(gap: 2) {
foreach (var it in items) { Text(it.Title); }
Input(value: draft, placeholder: "New item");
Button("Add", onPress: Add);
Button("Save", onPress: Save);
}
}
}The simplest form — create and Save in one action — is just the same pattern with new and UnitOfWork.Commit() in a single
Save handler:
entity Note { string Title; }
[Page("/notes/new")]
[Render(CSR)]
component NoteForm() {
string title = "";
action Save() {
new Note { Title = title };
UnitOfWork.Commit();
}
render {
Stack(gap: 2) {
Input(value: title, placeholder: "Note title");
Button("Save", onPress: Save);
}
}
}Editing a row — bind an input to its field#
An edit form binds an input straight to a field of a loaded row: Input(value: org.Name). This is a two-way
binding, exactly like binding to a client field — but the target is an entity field, so:
- the input pre-fills with the row's current value, and
- typing writes the change into the page's overlay (not the database) — the same optimistic overlay a
newaccrues into. The page becomes dirty as the user types (its tab shows the unsaved-work marker), and the edit is persisted only when a Save action callsUnitOfWork.Commit()— or discarded if the user closes the tab.
The row itself comes from a scalar server read (Single/FirstOrDefault), typically keyed by a route parameter, so
the page loads exactly the record being edited. There are three binding targets and no others: a client-field scalar
(Input(value: draft)), a loaded entity field (Input(value: org.Name)), and a Binding<T> prop this component was
handed (generic component).
⛔ A field of a class value is NOT one of them, and this is a data-model decision. A class is an in-memory
shape with no row behind it, so there is nothing to write through — the compiler refuses it rather than rendering a
box that quietly discards what is typed: "cannot two-way bind to k.Weight — k is a class … the edit would be
read-only." A live var is refused for the same reason: it is computed, so "there is nothing to write back into."
So if a page must let a person EDIT the rows of a list, those rows are an entity — hold the value in a
component field and copy it into the class when you save, or make the row a real entity. Deciding that up front is
much cheaper than discovering it once the page is written; the full target list is at [[ui-component#two-way]].
[Principal] entity User { [Required] string Email; }
entity Organization {
[Required] string Name;
[Required] string Slug;
security { allow create, read, update when IsAuthenticated; }
}
[Page("/org/{id}")]
[Render(CSR)]
component OrgEdit(Guid id) {
var org = Organization.Single(o => o.Id == id); // load the one row (route param → scalar query)
action Save() { UnitOfWork.Commit(); } // persist the edits the bindings accrued
render {
Stack(gap: 2) {
Input(value: org.Name); // pre-fills; typing dirties the page overlay
Input(value: org.Slug);
Button("Save", onPress: Save);
}
}
}For the row to be editable in the overlay it must be held in the page's own unit of work — a scalar server read on the page does exactly that. (An input bound to a row created on a different page/tab would be writing into the wrong overlay; each retained tab has its own.)
Calling a server function#
An action can call a server function mid-flow — to run logic that belongs on the server (a privileged read, a
cross-record calculation). You just call it like any other function; the hand-off happens under the hood (no await,
no ceremony). The boundary is seamless in both directions:
- The server function sees your pending edits — the page's uncommitted overlay travels with the call, so the server reads the same in-progress data the user is looking at.
- Whatever the server function creates or changes comes back, and the rest of your action reads it immediately — read-your-writes across the boundary.
⚠ From an [AllowAnonymous] page, the server function must be [AllowAnonymous] too. A signed-out visitor may
only hand off to a target that says it is public, so a plain server function called from a public page is
refused — and the refusal arrives at the call site, which unwinds the rest of the action: the write does not
happen and no statement after the call runs either. Marking the function [AllowAnonymous] does not open its
data: every read and write inside it is still gated by the entity's own security { }. This compiles clean today,
so it is the one part of the hand-off the compiler cannot yet warn you about.
Crucially, this keeps the same Save agency: rows the server produced ride back into the page overlay still
uncommitted — they show up in the UI, but the user's Save is what persists them, exactly like a local new.
The one exception is deliberate: if the server function itself calls UnitOfWork.Commit(), its own writes persist server-side
at that point (server logic can own its transaction). So "nothing persists until Save" holds for ordinary server
calls, and a server function only bypasses it by explicitly committing — which the compiler flags with a warning so
it's never a silent surprise.
action Reserve() {
var seat = AssignSeat(row); // server picks a seat, returns the row it created (hand-off is implicit)
note = "You got " + seat.Label; // read-your-writes: the returned row is visible here
} // …still uncommitted — the user's Save persists it,
// unless AssignSeat itself called UnitOfWork.Commit()See also#
- component — the component an
actionand its bound state live in - layout primitives — the
Stack/Input/Buttonatoms a form is built from - routes and pages — binding the form page to a route