Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne 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.

stable7 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 = 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 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 Osyrin.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 exactly the T the control declares. 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.

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.

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…