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 that runs in the browser 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, andosy validateandosy testprint it asDATA_NEVER_SAVED, naming the write.A function that runs on the server — one whose body reaches something only the server has (a secret, an outside call), which the compiler places there for you — is its own request, and its write is stored when it returns. So
Create(string title)that only builds a row loses it likeToggle(Item i)does, while the sameCreatethat also reads a secret keeps it. What the function TAKES does not decide this; where it RUNS does — so when in doubt, commit.
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 Osysharp.Ui;
entity Note {
[Required, MaxLength(200)] string Title;
security { allow read, create, update, delete when IsAnonymous || IsAuthenticated; }
}
[Route("/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);
}
}
}Is there anything to save, and what if they leave first?#
A Save button that is always live tells the reader nothing, and a page they can walk away from mid-edit loses their
work silently. Both are one read — Navigation.IsDirty, which is true exactly when this page holds uncommitted
edits and updates itself as they type:
entity Note { [Required, MaxLength(200)] string Title; }
[Route("/notes/{id}")] [AllowAnonymous] [Render(CSR)]
component EditNote(string id) {
var note = Note.Single(n => n.Id == id);
action Save() { UnitOfWork.Commit(); }
// ⭐ THE IDIOM. `disabled: !Navigation.IsDirty` — nothing typed, nothing to press.
render {
Stack {
Field("Title", value: note.Title);
Button("Save", onPress: Save, disabled: !Navigation.IsDirty);
}
}
}And the same read is what a leave guard asks: hold the destination, prompt, and offer Save
(Navigation.Save(path)) · Discard (Navigation.Close(path, true)) · Keep editing. That is Navigation
#in-actions and #closing, in full; the shells that ship with the platform do it for you on their own exits —
every nav row, and in SidebarShell the app switcher behind the brand (AppChrome.Apps) as well.
⚠ This is not UnitOfWork.Discard(). Discard throws away this body's pending writes; the leave guard is about
a route the user is walking away from, and it is the router — not the page — that can reach that route's unit of
work once the page is gone.
⚠ 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 every list on the page that reads every row of that type:Item.ToList(), ordered or with.Include(…)s. Add ten items across ten clicks and all ten show up, before anything is saved.A list with a filter or a window —
Item.Where(i => i.Done).ToList(),Item.OrderBy(…).Take(6)— does not show it yet: the filter runs on the server and the browser cannot ask whether a new row matches it, so rather than guess (a draft order under "Paid orders", seven rows in a list of six) that list takes the row from its own re-read afterUnitOfWork.Commit()reaches the server, about one round trip later. To show a parent's unsaved children, read them through the parent's collection —order.Lines— which follows the pending rows exactly (Child collections (navigating a relation)).⚠ 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: a list of every row of its type, read in the same
unit of work, shows it. 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: Inherit)(the page's Save persists it) orunitOfWork: Root(the dialog's own Confirm does) (Dialog.Open / Dialog.Ask / Dialog.Confirm / Dialog.Discard). A list reads through the unit of work it was mounted in, so the draft is visible to the dialog's own reads and to none of the page's lists — filtered or not — until the dialog hands it on; Cancel drops it everywhere. The draft stays an entity, so every[Required("…")]sentence is still declared once on the model (Validation). This is the one to reach for — an edit you can cancel belongs in a unit of work you can throw away. - 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; }
[Route("/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; }
[Route("/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 four binding targets and no others: a client-field scalar
(Input(value: draft)), a loaded entity field (Input(value: org.Name)), a Binding<T> prop this component was
handed (generic component), and an element of a list this component owns — or a field of one
(DecimalField(value: caps[i].Amount)).
⛔ A field of a BARE class value is not one of them, and that 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."
⭐ But a list this component OWNS is writable, elements and their fields alike — caps[i].Amount over a
Cap[] caps; field writes through the component's own signal, replacing the element and then the list. That is how a
page edits rows whose number belongs to the data rather than to the source. So the choice between a class and an
entity here is about where the rows LIVE — a class list is page state that a Save action sends somewhere, an
entity list is rows with identity that the overlay tracks and UnitOfWork.Commit() persists — and no longer about
whether a person can type into them. The full target list is at [[ui-component#two-way]], and the worked list form at
[[ui-component#editing-a-list]].
[Principal] entity User { [Required] string Email; }
entity Organization {
[Required] string Name;
[Required] string Slug;
security { allow create, read, update when IsAuthenticated; }
}
[Route("/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.)
Who saves an edit — the page#
The person's edit is already in the row: the bound input wrote it into the page's unit of work as they typed. So the
page saves it — UnitOfWork.Commit() in its Save action, or a SaveBar / SaveActions / AutoSave over the
page (The flow kit — a node-and-edge canvas whose nodes are your own Osy#). A function is for the part of the work that is not the person's edit — a computation, a
process, an outside call — and the page's save carries whatever it set:
entity Shelf {
[Required, MaxLength(80)] string Label;
[MaxLength(80)] string Slug = "";
security { allow read, create, update when IsAnonymous || IsAuthenticated; }
}
/// A computation — it answers, and writes nothing.
string SlugFor(string label) { return label.Trim().ToLower().Replace(" ", "-"); }
[Route("/shelves/{id}")]
[AllowAnonymous]
[Render(CSR)]
component EditShelf(Guid id) {
var shelf = Shelf.Single(s => s.Id == id);
action Save() {
shelf.Slug = SlugFor(shelf.Label); // the derived field, set on the row the page holds
UnitOfWork.Commit(); // the page saves — the person's edit and the derived field together
}
render {
Stack(gap: 2) {
Input(value: shelf.Label);
Button("Save", onPress: Save);
}
}
}⚠ Do not hand the edit back to a function to write and save. The shape below reads as tidy and is the one to
avoid; osy lint names it data-page-edit-saved-by-a-function:
void UpdateShelf(Shelf s, string label) { s.Label = label; UnitOfWork.Commit(); }
action Save() { UpdateShelf(shelf, shelf.Label); } // ✗ the save is the function's, not the page'sThat function runs on the server and writes on what it read. So when somebody else saved the field after this
page read the row, their value is overwritten without a word — even on a [[entity-concurrency-check|[ConcurrencyCheck]]]
field, where the page's own commit is checked against what the person saw and shows them the
conflict notice (their value and the new one, Keep theirs / Apply mine). A rule such a function
enforces ("only a draft can be edited") belongs on the entity — a rule, or allow update when … in its security
block — where every save meets it, the page's included.
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, a process, an outside call). Not to save the person's edit: that is the page's
([[ui-data-mutation#the-page-saves]]). 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:
- A row you hand it is the row the person edited — its pending edits travel with the call (see below). The page's other uncommitted edits do not: an ordinary call never carries the whole page.
- 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()A row you hand a function carries its edits#
Pass a row the page is editing to a server function and the function sees the values on the screen, not the row as it was stored — as it would in C#, where the object you pass is the object the callee reads. So a function that commits saves what the person typed:
public Bottle SaveBottle(Bottle b) { UnitOfWork.Commit(); return b; }
component BottleEditor(Guid id) {
var b = Bottle.Single(x => x.Id == id);
action Save() { SaveBottle(b); } // ✓ the label the person typed is what SaveBottle commits
render { Stack() { Input(value: b.Label, label: "Label"); Button("Save", onClick: Save); } }
}The edits are applied as the caller's own writes before the function runs — the same path, and the same checks, as
the page's own UnitOfWork.Commit(), including what the person was shown on a
[[entity-concurrency-check|[ConcurrencyCheck]]] field. They persist only if the function commits; if it does not,
they come back as the person's pending edits. Only the rows the call is handed travel — an argument typed as a row or a list of rows — never
another row the page has edited, and never a row named only by its id (Approve(report.Id) hands over a Guid).
An edit to a field the caller may not change itself does not travel. A field under deny update Checked except via MarkChecked changes only inside MarkChecked, so a page's tick on it is not the row's state: the function sees the
field as stored, and decides. The compiler warns when a control two-way binds such a field, because the page can never
save that edit — see [[security-except-via#from-a-page]].
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