Summary#
An Osy# component is reactive by construction. You declare what its values are; the runtime keeps them current and re-renders only what actually changed. There is no re-render call, no dependency array, no manual subscribe/unsubscribe. This guide is the mental model behind component's members — read it once and the rest of the UI reference falls into place.
Four kinds of member make up the model:
| Member | Runs | For |
|---|---|---|
live var x = … / a server read | continuously — a live value binding, recomputed whenever anything it reads changes | what a value IS |
var x = … | once, at setup — then it holds whatever it was last assigned | state you assign to |
on mount { … } | once, as the component first loads | one-time imperative setup |
on change { … } | at mount, then on every tracked change | a reaction — push a value outside the component |
on unmount { … } | once, at teardown (after children) | one-time teardown |
"Anything it reads" includes anything a helper it calls reads — WITH ONE EXCEPTION, AND IT IS THE CLOCK. A
live var that calls a method or a top-level function tracks the DATA that function read. It does not track a
clock read inside it, and the compiler refuses that shape rather than letting it go stale:
| Written | Recomputes when |
|---|---|
live var due = items.Where(i => i.At < DateTime.UtcNow).ToList(); | the clock ticks, or items changes |
live var due = items.Where(i => IsDue(i, DateTime.UtcNow)).ToList(); — the instant passed IN | the same |
live var due = items.Where(IsDue).ToList(); — bool IsDue(Item i) => i.At < DateTime.UtcNow; | ⛔ REFUSED |
⛔ THIS TABLE SAID "the same" FOR THE THIRD ROW UNTIL 2026-09-01, AND THE COMPILER HAS NEVER AGREED. Written exactly as that row showed,
osy validateanswers: "live duereads the clock throughIsDue(…), and a clock read inside a function does NOT advance — it is evaluated once… Reactivity is decided where the read is WRITTEN, not where the function is called." ⚑ And that refusal's ownfor morepointer names THIS PAGE, so a reader who followed it arrived at the sentence that had just been rejected. Measured on eval run 23 of012, which wrote the documented form, was refused, and spent four calls re-reading this page trying to reconcile the two. Read the clock where the value lives, or pass the instant in.
Description#
The lifecycle of an instance#
An instance runs this sequence, once:
Setup — declarations initialize.
var/live varfields take their initial value; server reads kick off (their rows stream in). Alive varis a value BINDING and stays current for the instance's whole life; a plainvaris a value you HOLD — its initialiser runs once and it changes only when something assigns to it."Once" means once the data it reads is there, as in C#, where a read finishes before the next line runs. A plain
varinitialised from a server read takes the loaded value, whether that read is the component's own or a parameter its caller worked out from one (PlanPanel(plan, prices.Where(…).ToList())). The only exception is a field that something assigned while the read was still loading: it keeps what was assigned.⚠ That distinction is the one readers get wrong. A derived value written as a plain
varlooks right, renders right on the first paint, and then never moves — so reach forlive varwhenever the value is computed from something that can change, and plainvaronly for state you assign to yourself.on mount— once, as the component first loads. One-time imperative setup: seed an editablenew Entity{}ghost, run a sequence, prime some state. It runs beside the first reads, and what it sets locally is on screen in the first paint; the paint does not wait for a server call it makes. (Data loading is a declaration — a query — neveron mount.)First render — the DOM is built once, and each dynamic slot (a text expression, a prop, an
ifcondition, aforeachsource) gets its own tiny reactive binding to exactly the values it reads.Steady state — a change wakes only what read it. When a value changes, only the slots and
on changeblocks that read that value re-run — not the whole page. A one-row edit patches one text node; an unrelated field elsewhere is untouched.on unmount— once, at teardown. Runs when the instance goes away, after its children have torn down. The instance's own live queries and reactions are disposed automatically at the same moment.
Declarations vs. on mount — the one distinction to internalize#
A declaration says what data is — a pure value binding that stays live:
live var open = orders.Where(o => o.Status == Status.Open); // recomputes when orders (or the filter) changeon mount is one-time imperative work — a new Entity{} you intend to edit (a declaration's new T{} is a plain
object, not a committable ghost), a load-time fetch, a startup side effect:
on mount { draft = new Organization {}; } // a committable ghost in the page overlay, seeded onceReach for a declaration first; reach for on mount only when the work is imperative and must happen exactly once.
What a live var may be#
Which one it is follows from its initializer:
| Initializer | What you get | Stays current because |
|---|---|---|
an entity read — Invoice.Where(…), from Invoice where … | a reactive query | it subscribes to data changes and refetches when its dependencies change |
a projected read — Folder.Select(f => new FolderNode { … }) | a reactive query of values | same subscription, but each row is a plain projected shape (see below) |
an expression over client values — draft?.Name ?? "…", a + b | a tracked computed | it recomputes synchronously whenever a value it read changes |
a call to a server function — Summary(chosen) | a live server call | it is asked again whenever an argument changes, or data its last answer read is written |
a stream<T> call — Tail(path), Ask(question) (yield — a function that produces results over time) | a live append-only list | the server pushes each item; when an argument changes it asks again ([[function-yield#follows-arguments]]) |
A server function as a live value
Write the call as the whole initializer. The function runs on the server, as the person viewing the page, and its answer is the value:
entity Sensor {
[MaxLength(80)] string Name;
security { allow read when IsAnonymous || IsAuthenticated; }
}
entity Reading {
Sensor Source;
double Value;
security { allow read when IsAnonymous || IsAuthenticated; }
}
[AllowAnonymous]
string Summary(Sensor? sensor) {
if (sensor == null) { return "pick a sensor"; }
var readings = Reading.Where(r => r.Source == sensor).ToList();
return sensor.Name + ": " + readings.Count + " readings";
}
[Route("/sensors")]
[AllowAnonymous]
[Render(CSR)]
component Sensors() {
Sensor? chosen = null;
live var sensors = Sensor.OrderBy(s => s.Name).ToList();
live var summary = Summary(chosen); // asked again when `chosen` changes, or a reading of it is written
action Choose(Sensor s) { chosen = s; }
render {
Stack(gap: 2) {
foreach (var s in sensors) { Button(s.Name, onPress: () => Choose(s)); }
Text(summary);
}
}
}It is asked again in two cases:
- An argument changes.
chosenmoves, so the page asks again with the new value. The previous answer stays on screen until the new one replaces it — a value that blanked while it was refreshed would make the page jump on every change — andLive.Pending(summary)is true meanwhile (below). Only the answer to the latest argument is ever kept. - Data its last answer read is written — by this page, another page, another person, on any server. The platform
records what the function actually read on each run, and listens for exactly that.
Reading.Where(r => r.Source == sensor)listens for readings of that sensor, so a reading of another sensor does not ask again. An answer that comes back the same is not sent.
Neither asks while the page is hidden — a page an Outlet(retain: N) keeps mounted while the reader is on another
one. A change it would have asked for is remembered, and the page asks once when it is shown again, however many
changes it missed.
Three things the compiler checks:
- The function may only read. A function that creates, changes or deletes a row, raises an event or starts a workflow is refused: it would be asked again on every change, its own included. Call it from an action instead.
- Something no commit announces. When the function reads the clock (
DateTime.UtcNow) or calls out over the network, nothing tells the page that the answer moved, so the compiler warns. Pass the time in as an argument and move it on a cadence (on every (TimeSpan.FromMinutes(1)) { now = DateTime.UtcNow; }). - A ticking clock as the argument.
live var now = DateTime.UtcNow;TICKS — solive var x = F(now);asks the server again on every tick, a request each time the clock moves. The compiler warns and names the three times that move only when the answer should: one read once (DateTime now = DateTime.UtcNow;), the day (DateOnly.FromDateTime(DateTime.UtcNow)), or one moved on a cadence as above.
The call must be the whole initializer. A server call inside a larger expression is refused, because the browser cannot wait for the server halfway through an expression:
live var label = "Now: " + Summary(chosen); // ✗ — make the call the whole initializer
live var summary = Summary(chosen); // ✓ and compute from it:
live var label = "Now: " + summary; // ✓ a tracked computed over the answerOne larger shape is still a single ask, and is allowed: a guard — a condition whose one branch is a constant and the other the call. The server evaluates the whole conditional, so with nothing chosen it answers the constant without calling the function, and it asks again when anything the initializer reads changes:
live var verdict = chosen == null ? null : CheckInVerdict(chosen, area); // ✓ null until a climber is chosen
live var kids = Session.CurrentUser == null ? [] : MyChildren(); // ✓ an empty list for a visitorThe constant is a literal — null, a number, text — or a collection of literals ([], new List<string>()), and
the function may call other server functions; it is asked as one call.
When a read of one entity is all the function does, the inline query is simpler and just as live:
live var readings = Reading.Where(r => r.Source == chosen). A client-side function is a tracked computed like
any other expression, so live var greeting = Shout(name) recomputes in the browser when name changes. When the
answer builds up over time instead of being replaced (an assistant's reply, a log), make the function a stream<T>
(yield — a function that produces results over time).
While its answer is on its way — and when it fails
A live server call is one of the page's first reads, exactly as a query is:
on mountruns once its answers are in. Every live call the component declares has answered for the arguments it reads now beforeon mountruns — a call whose argument is another call's answer included (settingsabove is asked again onceaccountarrives, andon mountwaits for that second answer). So the obvious edit form, which copies a saved value into a draft inon mount, opens on the saved value.- The body waits for them too. The
skeleton { }shows until then (skeleton); a component that declares none shows nothing in that moment. Either way the body never draws its empty state over an answer that has not arrived, and never reads one asnull:live var week = Cost();is typed by its function, so a helper taking a non-nullCostReportis handed the answer, not a stand-in. A body that readsLive.Pending(x)has said what to draw meanwhile, so it paints at once instead. A[Layout]waits the same way: itsOutletis part of its body, so the routed page is put into it once the layout's own live calls have answered. - A failure is not final. When the server refuses or faults, the call is failed; a server fault or a dropped connection is asked again on a short back-off first (a few seconds in all), so a blip is a pause, not a failure. A later change to what the answer read asks again, and its answer replaces the failure — the page does not freeze on one bad moment.
The page can say each of these itself. Live.Pending(x) is true while x's answer is on its way — before the first
answer, and while an argument change is being asked again. Live.Failed(x) is true when the last ask failed, and
Live.Error(x) says why (null otherwise). They read any live var: a query answers the same three questions, and a
value the browser computes is never pending. They belong in render or in a live var; to keep an action from running
early, gate its control — Button("Save", onPress: Save, disabled: Live.Pending(settings)).
entity Account {
[MaxLength(80)] string Name;
security { allow read, update when IsAnonymous || IsAuthenticated; }
}
entity AccountSettings {
Account Account;
int MinimumBalanceCents;
security { allow read, update when IsAnonymous || IsAuthenticated; }
}
[AllowAnonymous]
Account? CurrentAccount() { return Account.FirstOrDefault(); }
[AllowAnonymous]
AccountSettings? SettingsOrNull(Account account) {
return AccountSettings.Where(s => s.Account == account).FirstOrDefault();
}
[Route("/settings")]
[AllowAnonymous]
[Render(CSR)]
component SettingsPage() {
live var account = CurrentAccount();
live var settings = account == null ? null : SettingsOrNull(account);
int minBalance = 0; // a draft: nothing is written until Save
on mount { // runs once `account` AND `settings` have answered
if (settings != null) { minBalance = settings.MinimumBalanceCents; }
}
action Save() {
if (account == null) { return; }
var s = AccountSettings.Where(x => x.Account == account).First();
s.MinimumBalanceCents = minBalance;
UnitOfWork.Commit();
}
render {
Stack(gap: 2) {
if (Live.Failed(settings)) { Text("Could not load the settings: " + (Live.Error(settings) ?? "")); }
NumberField("Minimum balance", value: minBalance);
Button("Save", onPress: Save, disabled: Live.Pending(settings) || Live.Failed(settings));
}
}
skeleton { Text("Loading the settings…"); }
}Often you need no draft at all — bind the form to the row. A field bound to a row the page reads edits that row in
the page's unsaved changes; nothing is written until UnitOfWork.Commit(), and then only what was changed. There is no
copy to make, so there is no moment at which the copy can be wrong:
live var settings = AccountSettings.Where(s => s.Account == account).FirstOrDefault();
action Save() { UnitOfWork.Commit(); }
render {
if (settings != null) {
NumberField("Minimum balance", value: settings.MinimumBalanceCents); // edits the row itself
Button("Save", onPress: Save);
}
}A row a live server call answered binds the same way (live var settings = SettingsOrNull(account);). Keep a draft
when the page must hold an edit apart from the row — to compare, to validate across fields before anything changes, or
to offer Cancel without touching the row.
A projected live var — a live list of a shape you declared
A reactive query may project into a class (Select (projections)), exactly as a function return may — so a live var
can hold a live list of the shape you actually want to render, not the raw entity:
live var nodes = Folder.Select(f => new FolderNode { // a live list of FolderNode — reshaped, and reactive
FolderId = f.Id,
Name = f.Name,
ParentId = f.Parent?.Id ?? Guid.Empty
});It subscribes to the source entity (Folder) just like an entity read, so it refreshes on commit — create or delete
a folder and the list updates itself, with nothing to reload. What differs is the rows: a projection has no row
identity, so each row is a plain value of your shape rather than a tracked entity. You read its fields (node.Name)
and pass it on; there is simply no per-row entity to edit or track through it — the whole result refreshes together when
the source data changes. Reach for it wherever you would return a projected class from a server read, but want the
result to stay live instead of being fetched once.
Fine-grained updates — why "on change" is tracked, not "every render"#
on change is dependency-tracked: it subscribes to exactly the reactive values its body reads, and re-runs only when
one of those changes. That is the whole reason the name is on change and not "effect that runs every render" — a block
that re-ran on every render would be waste, and the name forbids that reading. Any reactive read counts, not only a
live var: a plain state field reassigned by an action wakes it too. See on change.
Can I hold editable state as class values?#
A class is an in-memory shape with no row behind it, so a component field holding a List<T> of
them is an obvious way to carry working state — a set of selections, a draft split, a basket of lines. The question
that follows is if I write to one of those objects, does the screen move?, and the answer has two halves that
point in opposite directions. Reading one is fully reactive. Binding a control to one is refused. Design against
one half alone and you will either write a reassignment you did not need, or an edit that cannot compile.
Reading a class field is tracked like any other read. A render slot that reads p.Weight subscribes to that
field, and a write through the object — from an action, through the list, through any alias of it, since a class is
a reference type ([[class-index#reference]]) — wakes exactly the slots that read it and nothing else. It is the same
fine-grained tracking as everything else on this page; a class value is not a blind spot in it.
⚠ So you do NOT need picks = picks.ToList(); after mutating an element. That reassignment is a reflex carried
in from frameworks that diff by list identity, and here it buys nothing: the write already re-rendered, and
rebuilding the list only makes the runtime redo work it had done. Write in place.
class Pick {
public string Name = "";
public int Weight = 1;
public Pick(string name) { Name = name; }
}
[Route("/picks")]
[AllowAnonymous]
[Render(CSR)]
component Picks() {
List<Pick> picks = [ new Pick("Ann"), new Pick("Bo") ];
int Total() { int t = 0; foreach (var p in picks) { t = t + p.Weight; } return t; }
action Bump(Pick p) { p.Weight = p.Weight + 1; } // in place — nothing reassigns `picks`
render {
Stack(gap: 3) {
foreach (var p in picks) {
Row(gap: 2) { Text(p.Name); Text("w=" + p.Weight); Button("Bump " + p.Name, onPress: () => Bump(p)); }
}
Text("Total shares: " + Total());
}
}
}
[Test]
void mutating_a_class_field_in_a_list_rerenders() {
Ui.Visit("/picks");
Assert.Visible("Total shares: 2");
Ui.Click("Bump Ann");
Assert.Visible("Total shares: 3"); // the derived total moved, from one field written in place
}But a control's two-way value: cannot target a class field. A two-way binding has to write back somewhere,
and a class value has no row behind it to write through — so rather than hand you a field that accepts typing and
saves nothing, the compiler refuses it:
foreach (var p in picks) { NumberField("Weight", value: p.Weight); } // ✗ refused, at compile timeERROR RESOLVE_ERROR UI: cannot two-way bind to `p.Weight` — `p` is a `class`, and a class value has no row behind
it to write through, so the edit would be read-only. A two-way target is an assignable field of this component, or a
field of an ENTITY. Hold the value in a component field and copy it into the class when you save it, or make the row
a real entity.Take the first of those two ways out and the whole thing works, because of the half above: bind an ordinary component field, then copy it into the class on save — the copy is an in-place write, so the derived total moves with it.
[Route("/picks/edit")]
[AllowAnonymous]
[Render(CSR)]
component PickEditor() {
List<Pick> picks = [ new Pick("Ann"), new Pick("Bo") ];
int draft = 1; // an assignable component field — this is what the control binds to
Pick editing; // which class value the draft is destined for
int Total() { int t = 0; foreach (var p in picks) { t = t + p.Weight; } return t; }
action Edit(Pick p) { editing = p; draft = p.Weight; }
action Save() { editing.Weight = draft; editing = null; } // the copy — an in-place write, so the screen moves
render {
Stack(gap: 3) {
foreach (var p in picks) {
Row(gap: 2) { Text(p.Name); Text("w=" + p.Weight); Button("Edit " + p.Name, onPress: () => Edit(p)); }
}
if (editing != null) {
Row(gap: 2) { NumberField("Weight", value: draft); Button("Save", onPress: () => Save()); }
}
Text("Total shares: " + Total());
}
}
}
[Test]
void a_component_field_carries_the_edit_into_the_class() {
Ui.Visit("/picks/edit");
Assert.Visible("Total shares: 2");
Ui.Click("Edit Ann");
Ui.Fill("Weight", "4");
Ui.Click("Save");
Assert.Visible("Total shares: 5");
}So pick the shape by how the value is produced, not by how it is stored. A class value is an excellent carrier for anything derived — a computed balance, a settlement transfer, a running subtotal, a projected row — because those are written by your own code and read by the render, which is exactly the half that works. It is the wrong carrier for anything a person edits directly through a control: there, either keep the edited scalar in a component field and copy it across as above, or make the row a real entity and bind to that. Both are ordinary; neither needs the list reassigned.
Automatic disposal — the ease of live without the leak#
Every reactive thing a region creates — a live query's subscription, an on change reaction, a child component — is
owned by that region's scope. When the region leaves (a foreach row drops, an if branch flips, the page closes),
its scope disposes and takes all of that down with it, children-first. You never write the un-subscribe; forgetting to
stop a live-ness is not a bug you can have here.
Examples#
All four kinds on one page — a declaration feeds the render and a reaction; on mount seeds; on unmount closes out:
entity Organization { string Name; }
[Route("/org/new")]
[Render(CSR)]
component OrgCreate() {
Organization draft;
on mount { draft = new Organization {}; } // once, before paint — seed the editable ghost
live var tabName = draft?.Name ?? "New organization"; // a live declaration — recomputes as you type
on change { Navigation.SetTitle(tabName); } // a reaction — re-runs only when tabName changes
on unmount { Log.Information("create form closed"); } // once, at teardown
render {
Stack(gap: 4) { Input(value: draft.Name, placeholder: "Organization name"); } // the text slot tracks draft.Name
}
}See also#
- component — the member table this model underlies.
- on mount / on unmount —
on mount/on unmountin full. - on change — the tracked reaction, in full.
- creating & saving data — how a seeded
new Entity{}ghost rides the page overlay andUnitOfWork.Commit(). - Classes — what a class value is, and why an edit through a list index sticks ([[class-index#reference]]).