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, before first paint | 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.⚠ 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, before the first paint. One-time imperative setup: seed an editablenew Entity{}ghost, run a sequence, prime some state. Because it runs before first render, what it sets is already on screen in the first paint. (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 — and why an ordinary server function isn't one#
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 stream<T> call — Tail(path), Ask(question) (yield — a function that produces results over time) | a live append-only list | the stream is the subscription — the server holds the connection open and pushes, so there is nothing to poll and nothing to invalidate |
A call to an ordinary server function is none of these, so it is a compile error:
live var files = FilesInFolder(selectedId); // ✗ FilesInFolder runs on the serverNothing subscribes such a value to data changes, and a tracked computed cannot recompute synchronously — it would have to hand off to the server mid-render. Both ways out are spelled out by the diagnostic:
var files; // ✓ fetch once, imperatively
on mount { files = FilesInFolder(selectedId); }
live var files = FileAsset.Where(f => f.FolderId == selectedId); // ✓ inline the query — genuinely reactiveAnd a third, when the answer builds up rather than changing — make the function a stream<T> (yield — a function that produces results over time).
That removes the objection rather than working around it: a stream is its own subscription, so each result renders the
moment it arrives.
stream<FileInfo> FilesInFolder(Guid id) { … yield return file; … } // the producer
live var files = FilesInFolder(selectedId); // ✓ items appear as they are foundInlining the query is almost always what you actually wanted: it re-runs when selectedId changes and when the
underlying rows change, which is the behaviour the server-function spelling only appeared to offer.
A client-side function is fine — it is a tracked computed like any other expression, so live var greeting = Shout(name) compiles and recomputes when name changes. The rule keys on where the callee runs, not on the fact
that it is a call.
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; }
}
[Page("/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.
[Page("/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; }
[Page("/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]]).