Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

creating & saving data

action Save() { new Entity { … }; UnitOfWork.Commit(); } — mutate optimistically, then commit to the server

A UI `action` creates, updates and deletes data by writing `new Entity { … }`, assigning fields, and calling `.Delete()`. Edits apply instantly and stay visible while the user keeps working — a list of every row of that type reads a new row back before anything is saved, and a filtered list takes it from its own re-read once it is. `UnitOfWork.Commit()` sends the accumulated edits to the server atomically. A **form** commits once, on Save; a page that saves **per action** — ticking a to-do IS the save — commits in each verb. Both are correct; what is never correct is a page with no Save and no `UnitOfWork.Commit()`, where the write is discarded with no error.

stable6 examples compiled by CIuiauthoringdata

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:

shapeexamplewhere UnitOfWork.Commit() goes
form — nothing persists until Savean edit screen with a Save buttononce, in the Save action
per action — the act IS the saveticking a to-do, archiving, deleting a rowin 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 lint catches this shape as data-write-never-committed, and osy validate and osy test print it as DATA_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 like Toggle(Item i) does, while the same Create that 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, atomically

Deleting 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 after UnitOfWork.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's live var, which already holds the rows including the pending ones, or UnitOfWork.Commit() first and then read.

  • e.Field = value records 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:

  1. a dedicated Save action (a Save button), and
  2. 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) or unitOfWork: 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 new accrues 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 calls UnitOfWork.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's

That 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#

Related

component

The one archetype for all UI: a bounded reactive unit — typed props, reactive members (fields, `live`…

routes and pages

How a component becomes a page: it declares a route with `[Route("/catalog/{slug}")]`, and navigating to a matching…

layout primitives

The built-in layout primitives and how they arrange children. `Stack` stacks children in a column, `Row` lays them in a…

generic component

A component with type parameters, bound at each call site from the arguments — one component that works over any enum…

Validation

You declare a field's rules once, on the entity — `[Required]`, `[Pattern]`, `[MaxLength]` — and give each rule the…

Dialog.Open / Dialog.Ask / Dialog.Confirm / Dialog.Discard

Opens a component in an overlay above the current screen, with a unit of work you choose: `Inherit` makes its edits a…