Osy#betaa language · its runtime Osyrin · a hosted platform
Why Osy#Built for agentsAgents as declarationsWorkflows that waitRuns exactly onceSecure by defaultNothing to mockThe editor is the compilerUI in the languageDocuments are dataOne program

Reference / UI

component

component Name(props) { <fields>; live var; action; on mount; render { … } }

The one archetype for all UI: a bounded reactive unit — typed props, reactive members (fields, `live` queries/computeds, actions, methods), and a declarative render tree. A page is a component bound to a route.

stable10 examples compiled by CIuiauthoring

Summary#

A component is the ONE archetype for all UI: a bounded reactive unit with typed props, reactive members, and a declarative render tree. A page is a component bound to a route ([Page]); a list row, a dialog, and the app shell are all components. The compiler lowers a component to typed metadata; the client runtime fetches that tree and renders it — apps never ship JavaScript.

Signature#

[Page("/route/{param}")]        // optional: bind to a route (route params bind to same-named props)
[Render(SSR|SSG|ISR|CSR)]       // delivery mode; unrouted components inherit their host's
[AllowAnonymous]                // opt this route out of auth (public); routes require auth BY DEFAULT
[Authorize(Policy)]             // optional: narrow a (default-)protected route to a named policy (checked reference)
component Name(Type prop, …) {
  Type name = init;                // component field — a client SNAPSHOT (assignable; frozen until reassigned)
  var name = Entity.ToList();      // a server read, taken ONCE (a snapshot; not kept in sync)
  live var name = Entity.ToList(); // a reactive query — kept current via the change channel
  live var name = expr;            // a tracked computed — client-side; may call and allocate, never the server
  action name(params) { … }        // event handler (client interpreter)
  RetType name(params) => expr;    // plain helper method (or { block })
  render { … }                     // the declarative render tree
}

Description#

Where does a component keep its state?#

A component is a class, and its fields are its reactive instance state — so there is no state/query/derived keyword to declare one. A Type name = init; (or var name = …;) written directly in the component body, above render is a reactive field — the same place a C# class puts its fields; whether it holds a client value or a server read is inferred from the initializer, not spelled: an initializer that reads an entity set (Order.ToList(), User.Single(…)) is a server read; anything else — a scalar, a new Entity{} ghost, a bare field, or a query over a local collection — is a client value. (Method-locals inside an action/method stay transient, as in any class.)

One reactive marker: live. A live var tracks — it stays in sync with what it reads:

  • live var xs = Entity.ToList(); is a reactive query (a data-change signal refetches it);
  • live var y = <expr>; is a tracked computed (it recomputes when its inputs change).

A naked var is a snapshot — it reads its value once and freezes. It is a cut: reactivity does not flow through it. var rows = xs.ToList(); copies xs's current rows and never updates again, even as xs does; a refresh is a new assignment, not an implicit update. So use live var when a value should stay current, and a plain var when you want a fixed copy.

Holding WHICH ROW an editor or a dialog is for is a field like any otherAlbum? editing; — and it is the question people arrive at this section already asking. It has its own answer below: [[#row-in-state]]. You do not need an id, and you do not need per-row state inside the foreach: a component member is ONE thing, and the row travels to the action as an argument.

MemberDeclaresEvaluates
T name = init; / var name = init;A client snapshot fieldInitializer at mount; assignable — a write re-renders. Frozen until reassigned.
Type name;A field with no initializerDefaults to null, assigned later (e.g. an on mount ghost seed).
var name = Entity.ToList();A server read, once (a snapshot)Runs on the server; rows land in the shared client store, then freeze. Exposes .items / .total / .loading / .error / .hasMore.
live var name = Entity.ToList();A reactive server queryThe same read, kept current — a data-change SIGNAL (never row values) triggers a refetch.
live var name = Entity.Single(x => x.Slug == slug);A reactive query bound to a valueThe predicate may reference the component's props (a route param) and its own fields / computeds — each captured value is sent to the server read, and the query refetches when it changes. So a search box keys a list off a filter field, and a [Layout] keys a lookup off a computed slug; the query rebinds in place with no remount.
live var name = Entity.Where(x => x.Ref == row).ToList();A reactive query keyed on a row the component holdsA query may compare a reference against a row another member loaded (i.Organization == org), and may guard on the row (org != null && …, org is not null). Both bind the row's id — the one member every client-held row carries — so the query sends a scalar, and an unloaded row answers no rows rather than a refusal. Any other member of a captured row (org.Name) is not sent: lift it into its own live var and key on that.
live var name = expr;A tracked computed valueRecomputed on the CLIENT when its inputs change. It may call a helper and allocatelive var shares = Settle(people, pot); is a computed, not a query. The one thing it may not do is reach the server; that is the query form above. Not assignable.
action name(params) { … }An event handlerOn the client interpreter, invoked by an event prop.
on change { … }A reactive side-effectA tracked reaction: re-runs when a value it READ changes, to push that value somewhere OUTSIDE the component (e.g. on change { Navigation.SetTitle(org.Name); }). It may not assign the component's own state — that's a compile error. See on change.
on mount { … } / on unmount { … }Lifecycle bodiesAuto-invoked ONCE — on mount before first paint (seed a draft, kick off a load), on unmount at teardown (a final flush). See on mount / on unmount.
RetType name(params) => expr; / { … }A plain helper methodLike an action; an expression body is a one-return function. A PURE one is callable from a render expression (Text(Subtotal())) — see Calling helpers from render.

A query keyed on a row is written the way C# reads it. The guard and the comparison are both identity questions about org, and the compiler keys the read on org.Id for you; nothing has to be lifted by hand.

using Osysharp.Ui;

entity Organization { [Unique, MaxLength(60)] string Slug; [MaxLength(100)] string Name; }
entity Invitation { Organization Organization; [MaxLength(200)] string Email; }

[Page("/org/{slug}/invites")]
component InvitesPage(string slug) {
  live var org = Organization.Where(o => o.Slug == slug).FirstOrDefault();
  live var invitations = Invitation.Where(i => org != null && i.Organization == org).ToList();
  render { Stack(gap: 2) { foreach (var i in invitations) { Text(i.Email); } } }
}

A live var computed may CALL, and it may ALLOCATE. It is an ordinary client expression that happens to be tracked, so the whole computation can live in one helper and be named rather than smeared through render. There is no purity budget to spend: a method that builds a new List<T> and returns it is a perfectly ordinary initializer.

```osy title="a live var computed calling a helper that allocates" test app=ui-component-live-computed using Osysharp.Ui;

class Share { public string Who; public decimal Amount; }

entity Person { [Required, MaxLength(80)] string Name; security { allow read, create when IsAuthenticated || IsAnonymous; } }

[Page("/split")] [AllowAnonymous] [Render(CSR)] component SplitPage() { live var people = Person.ToList(); // the reactive QUERY — a server read, kept current decimal pot = 90m; // an ordinary client field

live var shares = Settle(people, pot); // the tracked COMPUTED — it CALLS, and it ALLOCATES

List<Share> Settle(List<Person> ps, decimal total) { var rows = new List<Share>(); foreach (var p in ps) { rows.Add(new Share { Who = p.Name, Amount = total / ps.Count }); } return rows; }

render { Stack { foreach (var s in shares) { Text($"{s.Who} owes {s.Amount}"); } } } }


**The one line it cannot be is a SERVER call.** A `live var` is exactly two things — a reactive query (an entity
read, which subscribes to data changes) or a tracked computed over values the client already holds. A call that runs
on the server is neither, and the compiler says so by name: *"`live n` cannot be initialized from `PeopleCount(…)` —
that runs on the server."* Either fetch it once (`Type n; on mount { n = PeopleCount(); }`) or inline the query
(`live var n = SomeEntity.Where(…)`).

**Members share one namespace.** A component is a class, so no two of its members may share a name — a field and an
action collide just as two fields do, because both are reached as `this.name`. Declaring the same name twice is a
compile error naming both declarations, exactly as it is in C#:

```osy title="✗ a field and an action cannot share one name" syntax
component Editor() {
  int Save = 0;
  action Save() { }        // error: component 'Editor' already declares 'Save' — the field on line 2
}

Members that declare no name at all — on mount, on unmount, an unnamed on change — cannot collide, so a component may have as many as it needs.

What can an input write back into?#

Binding an input writes back through what you bound, so the target has to be something that can be written. Three things are:

targetexample
an assignable field of this componentstring draft = "";Field("Draft", value: draft)
a field of an ENTITY row the page holdsField("Name", value: p.Name) inside foreach (var p in people)
a Binding<T> prop of this componentcomponent WeightRow(Binding<decimal> weight)NumberField(l, value: weight)

And the target's type must be the T the control declares, with one exception named below. A binding is two-way: the control reads a T out of the target and writes a T back into it, so there is no conversion to insert — a conversion would need an inverse, and one that has an inverse is the same type under another name. DatePicker declares Binding<DateOnly>, so a DateTime field is a compile error, and so is widening an int into a Binding<decimal>. The refusal names the control that binds the type you are holding (DateTimePicker for a DateTime, DecimalField for a decimal) — call that one, or declare the field as the control's T and convert wherever you SET it. A conversion written in the bind itself is not a two-way target and is refused for that instead.

The exception: a number in a text field

A number may be bound to a Binding<string>. Field declares one, so Field("Rate", value: rate) over a decimal is legal: the value is formatted into the box on the way out and parsed back into the member's declared type on the way in, through that type's own exact parse. What you type is what the member holds — a number, not text that looks like one — so rate + 1 adds.

It is the only conversion a two-way bind admits, because a number is the only thing that answers both of the questions a free-text box asks: can a reader type this value, and does what they typed come back as the value they meant. A closed set fails the first — nothing stops someone typing a word that is not a member, and the write is refused later with nothing on screen to say so — and a date fails the second, since the same day has several spellings and the reader's culture picks one. Both stay compile errors.

using Osysharp.Ui;

[Page("/rate")]
[AllowAnonymous]
[Render(CSR)]
component RatePage() {
  decimal rate = 0m;
  render {
    Stack {
      Field("Rate", value: rate);            // legal — formatted out, parsed back in
      DecimalField("Hourly rate", value: rate, min: 0m, prefix: "£");
      Text($"doubled: {rate * 2}");
    }
  }
}

Reach for NumberField (whole) or DecimalField (money, a measurement, a rate) anyway. They bind the number directly and give the box what a text field cannot: steppers, a min/max the browser itself enforces, and the numeric keyboard on a phone. osy lint says so as a SHOULD-tier finding (ui-text-field-bound-to-a-number) and names which of the two fits your type. Text that is not a number at all lands the member on the same value an empty box does, which is what those controls do with it too.

Two things are not targets at all, and both are compile errors rather than a silently read-only box:

  • A field of a class value. A class is an in-memory shape with no row behind it, so there is nothing to write through — "cannot two-way bind to k.Weightk is a class … the edit would be read-only." Hold the value in a component field and copy it into the class when you save, or make the row a real entity.
  • A live var. It is computed, so "there is nothing to write back into."

This decides a data model, not a line of markup: if a page must let a person EDIT the rows of a list, those rows are an entity. See [[ui-data-mutation#edit-binding]] for the entity-field form, and generic component for Binding<T>.

Imperative bodies — the receiver model#

action / on change / on mount / on unmount / method bodies resolve function-style with the component as the receiver — the same member-body mechanism class methods use (class methods):

  • A bare member name is an implicit-this member: count = count + 1this.count = this.count + 1. Both spellings are legal (C# scoping); locals and parameters shadow members.
  • Only a client snapshot field is assignable. Assigning a server read, a live var computed, or a prop is a compile diagnostic (a server read is a read-only handle; a live var computed is derived from its inputs).
  • No await. A call to a server function inside an action is a plain call — Login(email, password); — and the runtime hands off by the callee's execution side, not by a keyword. (await exists only for Workflow.Run.)
  • Declarative slots (field initializers, live var computeds, render expressions) instead see members as ambient names — the reactive scope the renderer evaluates.

The render tree#

Statements in render { } are declarative nodes, persisted as the component's typed render tree:

FormMeaning
Stack(gap: 2) { … }A platform atom. The atom set is deliberately tiny (Stack, Box, Text, Button, Pressable, Image, Input, Link) — richer surfaces come from foreign controls, never new natives.
Card(p.Name)A call to another component. Props bind positionally / by name; the child contributes its render OUTPUT (no wrapper element).
Text(expr)Text content — any value expression over the component scope.
if (…) { } else if (…) { } else { }A reactive conditional chain.
foreach (var x in source) { }Iteration over a member/prop collection or an inline query.
Button("Save", onPress: Save)An event prop binds an action/method by name: onClick, onInput, onChange, onSubmit.
var n = rows.Count;An ordinary local, legal wherever a render statement is — including inside a control's or an atom's child block.
several statements at the TOP levelA render block takes many siblings: render { Text("a"); Text("b"); } compiles. A Stack/Box is for LAYOUT, never to make the tree well-formed.

A control's content block IS a render block — same grammar, same locals, all the way down:

entity Kiln { string Name; }

component Card(string title) {
  render { Stack(gap: 1) { Text(title); Slot; } }
}

[Page("/kilns")]
[Render(CSR)]
component Kilns() {
  var kilns = Kiln.ToList();

  render {
    Text("Kilns");               // MULTIPLE top-level siblings — legal, no wrapper needed
    Text("—");
    Card("In the house") {        // a control's CONTENT BLOCK is a render block…
      var n = kilns.Count;       // …so a local, an `if` and a `foreach` are all legal inside it
      Text($"{n} kilns");
      if (n == 0) { Text("none yet"); }
      foreach (var p in kilns) { Text(p.Name); }
    }
  }
}

What ships to the client, and what stays on the server?#

  • The component TREE ships to the client; expression slots ride the same wire union as function bodies — one expression currency, one interpreter.
  • A server-read field ships only its persisted root id; the client reads the rows from the server, and a live refetch reads them the same way.
  • A routed component is public only when it declares [AllowAnonymous] — a component takes no public/internal modifier (writing one is an error). Who may reach a component is an authorization question ([AllowAnonymous], [Composable], [Authorize]), not a type-visibility one, so the type has no visibility axis to set.
  • Actions run on the client against component state; entity writes go through the optimistic overlay and commit on the server.

Holding ONE row in state — Album? editing;#

A field may hold a single entity, not only a list — Album? editing; is the ordinary way to say which row the dialog is for. It is a client value like any other field (a bare field, not a server read), it compares with ==, and null is the honest spelling of "nothing selected".

You do not need an id. Holding Guid editingId; and looking the row up again on every render is the shape people reach for when they are unsure this is allowed — it is more code, it re-finds a row you already had, and it loses the type. Hold the row.

entity Album {
  [Required, MaxLength(120)] string Title;
  security { allow read, create, update when IsAnonymous; }
}

[Page("/albums")]
[AllowAnonymous]
component Albums() {
  live var albums = Album.OrderBy(b => b.Title);

  Album? editing;                                  // one row, or none
  string draftTitle = "";

  action Edit(Album b) { editing = b; draftTitle = b.Title; }
  action Cancel() { editing = null; }

  render {
    Stack {
      foreach (var b in albums) {
        Stack(role: UiRole.Group) {
          Text(b.Title);
          Button("Edit", onPress: () => Edit(b));
        }
      }
      if (editing != null) {
        Stack(role: UiRole.Group, label: "Edit album") {
          Input(value: draftTitle, label: "Title");
          Button("Cancel", onPress: Cancel);
        }
      }
    }
  }
}

A field per ROW is the thing that does not workforeach renders one component body many times over, so a single borrowerName field is shared by every row and typing in one types in all of them. Two shapes are right, and neither needs per-row state: open ONE editor at a time against a held row (above), or give the row its own component, whose fields are then genuinely its own.

Binding a click to an action — event handlers#

An event prop takes one of your component's actions. Two forms:

[Principal] entity User { [Required] string Email; }

entity Item {
  [Required] string Label;
  security { allow create, read, update, delete when IsAuthenticated; }
}

[Page("/toolbar")] [Render(CSR)]
component Toolbar() {
  var rows = Item.OrderBy(r => r.Label).ToList();
  action Save() { UnitOfWork.Commit(); }
  action Remove(Guid id) { var r = rows.Single(x => x.Id == id); r.Delete(); }   // over the rows already fetched

  render {
    Row {
      Pressable(onClick: Save) { Text("Save"); }                 // no arguments — name the action
      foreach (var row in rows) {
        Pressable(onClick: () => Remove(row.Id)) { Text("×"); }  // pass an argument
      }
    }
  }
}

() => Remove(row.Id) reads like C# and behaves like it: the argument is captured where the handler is written. Inside a foreach, each row's button carries that row's id — the × removes the row you clicked, not the last one drawn.

The lambda takes no parameters, and its body must call something. A handler that doesn't call anything would do nothing, so it's a compile error rather than a button that silently ignores you:

Pressable(onClick: () => count)          // error: a handler's body must CALL an action or method
Pressable(onClick: () => Remove())       // error: 'Remove' takes 1 argument(s) but 0 were given
Pressable(onClick: () => delete(row.Id)) // error: 'delete' is not an action, method, or callback parameter

A component that takes a callback parameter can forward it the same way, which is how a shared component (a tab, a table row) reports back what happened to it. A callback parameter is spelled as a C# delegate: Action for a no-argument callback, Action<T…> for one that carries values (Action<string> onClose), and Func<T…, TResult> for an accessor that returns a value (Func<Row, bool> predicate).

[Composable] component Tab(string path, Action<string> onClose) {
  render { Pressable(onClick: () => onClose(path)) { Text("×"); } }
}

A callback parameter can also be invoked from an imperative body — an action, method, or on change block — not only from a render lambda. This is what lets a component do its own work first and then report back. A menu that closes itself (a state write, which needs a body) before telling its parent where to go:

[Composable] component Switcher(string path, Action<string, bool> onLeave) {
  bool open = false;
  action Pick(bool dirty) {
    open = false;          // close the menu…
    onLeave(path, dirty);  // …then hand the destination to the parent
  }
  render { Pressable(onClick: () => Pick(true)) { Text(path); } }
}

The call is checked against the delegate's parameters exactly like the render-lambda form (onLeave(path) alone would be a "takes 2 argument(s)" error). A callback is fire-and-forget from a body: it hands control to the parent's action and evaluates to nothing, so it is a statement, not a value.

How does a caller wire up a callback?#

The caller supplies a callback parameter by naming an actiononPick: Pick — the same way a render lambda names one for onClick. This is what makes a shared row reusable, over any element type (a string, an entity, whatever the list holds): one component, composed once per item, each instance carrying its own row and reporting back through the same callback.

[Composable] component ItemRow(Item item, Action<Item> onPick) {
  render { Pressable(onClick: () => onPick(item)) { Text(item.Label); } }
}

[Page("/items")] [Render(CSR)]
component ItemList() {
  live var items = Item.ToList();
  Item? picked = null;
  action Pick(Item item) { picked = item; }
  render {
    Stack {
      foreach (var item in items) { ItemRow(item, onPick: Pick); }
    }
  }
}

The composition itself must name an action — an INLINE LAMBDA there is refused, even though the render-lambda one line up (onClick: () => onPick(item), inside ItemRow) is exactly the shape this page opened with. The difference is WHERE the callback is invoked from: onPick is called by ItemRow's own onClick, so composing it has to hand the platform's event system something it can call directly with the clicked row — a bound action, never an arbitrary expression:

foreach (var item in items) {
  ItemRow(item, onPick: it => { Pick(it); Extra(); });   // error: bind by naming an action, not a lambda
}

If the caller needs something ItemRow does not pass — which page, group or column the row sits under — that context is the CALLER's own to carry, not something to close over: widen the delegate to Action<Context, Item> and invoke it as onPick(context, item), so the value travels with the clicked row instead of being captured at composition.

Examples#

entity Product { bool Active; string Name; }

component Card(string label) {
  render { Text(label); }
}

[Page("/catalog/{slug}")]
[Render(CSR)]
component Catalog(string slug) {
  int count = 0;
  live var label = slug;
  var products = Product.Where(p => p.Active).ToList();
  live var top = Product.OrderByDescending(p => p.Name).Take(3).ToList();

  action Increment() { count = count + 1; }
  int doubled(int x) => x * 2;

  render {
    Stack(gap: 2) {
      Text("Catalog");
      foreach (var p in products) { Card(p.Name); }
      if (count > 0) { Text("has"); } else { Text("none"); }
      Button("+", onPress: Increment);
    }
  }
}

See also#

Related

component

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

class methods

Behaviour attached to a class — a method with a receiver, called as value.Method(). Classes are in-memory values, so a…

type visibility (public / internal)

A top-level type carries a public or internal visibility that decides whether code outside its namespace can name it…