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 ([Route]); 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#
[Route("/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 other — Album? 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.
| Member | Declares | Evaluates |
|---|---|---|
T name = init; / var name = init; | A client snapshot field | Initializer at mount; assignable — a write re-renders. Frozen until reassigned. |
Type name; | A field with no initializer | Defaults 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 query | The 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 value | The 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 holds | A 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 value | Recomputed on the CLIENT when its inputs change. It may call a helper and allocate — live 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 handler | On the client interpreter, invoked by an event prop. |
on change { … } | A reactive side-effect | A 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 bodies | Auto-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 method | Like 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; }
[Route("/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; } }
[Route("/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}"); } } } }
**A server function works too, as the whole initializer.** `live var n = PeopleCount(team);` is asked on mount and
asked again whenever `team` changes or data its last answer read is written ([[ui-reactivity#server-call]]). What
the compiler refuses is a server call INSIDE a larger expression (`live var label = "n=" + PeopleCount(team);`):
make the call the whole initializer, and compute from its answer in a second `live var`.
**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. Four things are:
| target | example |
|---|---|
| an assignable field of this component | string draft = ""; → Field("Draft", value: draft) |
| a field of an ENTITY row the page holds | Field("Name", value: p.Name) inside foreach (var p in people) |
a Binding<T> prop of this component | component WeightRow(Binding<decimal> weight) → NumberField(l, value: weight) |
| an element of a list this component owns, or a field of one | Cap[] caps = […]; → DecimalField(caps[i].Label, value: caps[i].Amount) |
Passing a binding on
A Binding<T> prop is not a copy of the caller's value — it IS the caller's target. So a component can hand it on,
to a kit control or to another component of its own, and the write lands on the ORIGINAL owner however many
components it passes through. Nothing in between declares a field or an action to relay it.
using Osysharp.Ui;
// Owns nothing: `showSteps` is the page's field, handed in and handed on.
[Composable]
component RecipeCard(string title, Binding<bool> showSteps) {
render {
Stack(gap: 2) {
Text(title, fontWeight: FontWeight.Bold);
Switch("Show the steps", value: showSteps); // Switch declares `Binding<bool> value`
if (showSteps) { Text("1. Warm the pot. 2. Add the leaves. 3. Wait four minutes."); }
}
}
}
[Route("/recipe")]
[AllowAnonymous]
[Render(CSR)]
component RecipePage() {
bool showSteps = false;
render {
Stack(gap: 2) {
RecipeCard("Green tea", showSteps);
Text(showSteps ? "steps shown" : "steps hidden"); // the page's own field moved
}
}
}A list travels the same way: a Binding<string[]> items prop binds its elements (Field(value: items[i])) exactly as
the owner's own field would.
A plain parameter is not a target. component RecipeCard(bool showSteps) receives a VALUE, which belongs to the
caller — so binding it (Switch(…, value: showSteps)) or assigning it in an action is refused, naming the parameter
and the declaration that makes it one: "'showSteps' is a read-only parameter … Declare it Binding<bool> showSteps".
Editing a list
The fourth row is what lets a page edit rows it cannot name one by one. The first three targets are all named at compile time, so a list whose length belongs to the data — five budget dimensions, one filter per column, whatever the user just added — had no editable spelling at all: a field per element is precisely what it cannot have.
Two rules, and both are the same rule one level apart:
- The list must be an assignable field of this component. A
live varis computed, so a write into it would be dropped, and the compiler says so rather than rendering a box that takes typing and changes nothing. - The write replaces, it does not mutate. The element is replaced, then the list is — so both the element and the field hold new references and everything reading either one repaints. You never write that; it is what the binding does.
The element may be a scalar (string[] typed; → Field(value: typed[i])) or a class. If it is an entity row,
bind one of its fields rather than the element: a row has an identity, so orders[i].Name writes through the row
itself and the page goes dirty exactly as order.Name does.
using Osysharp.Ui;
class Cap { public string Label = ""; public decimal Amount = 0m; }
[Route("/caps")]
[AllowAnonymous]
[Render(CSR)]
component CapsPage() {
Cap[] caps = [
new Cap { Label = "Tokens", Amount = 100m },
new Cap { Label = "Storage", Amount = 25m },
];
live var total = caps.Sum(c => c.Amount);
render {
Stack(gap: 2) {
foreach (var i in Enumerable.Range(0, caps.Length)) {
DecimalField(caps[i].Label, value: caps[i].Amount, min: 0m);
}
Text($"total {total}");
}
}
}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>.
A nullable target needs a control that can be empty. A DateOnly? binds to DatePicker (its Binding<DateOnly?>
shows a placeholder while empty), but an int? or a decimal? into a control declaring Binding<int> /
Binding<decimal> is refused naming the parameter: that control has no empty state, so a null would reach its render
and the page would fail in the browser. A string? is fine — a text box shows null as empty. The other direction is
always legal: a non-null DateOnly into Binding<DateOnly?>, since the control can hold everything the field can. 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;
[Route("/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
classvalue. Aclassis an in-memory shape with no row behind it, so there is nothing to write through — "cannot two-way bind tok.Weight—kis aclass… 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 realentity. - 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-
thismember:count = count + 1≡this.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 varcomputed, or a prop is a compile diagnostic (a server read is a read-only handle; alive varcomputed 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. (awaitexists only forWorkflow.Run.) - Declarative slots (field initializers,
live varcomputeds, 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:
| Form | Meaning |
|---|---|
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 level | A 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; } }
}
[Route("/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 nopublic/internalmodifier (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; }
}
[Route("/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 work — foreach 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; }
}
[Route("/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.
Can a handler's body be a block that reads the row?#
Yes. A handler's body may be any statement C# allows there — an assignment, an increment, or a whole { … } block —
and a block closes over the names around it exactly as the expression form does: the foreach row, and the
parameter of a slot template (Lines(items) { l => … }). Each button keeps the row it was BUILT for.
using Osysharp.Ui;
class Track { public string Title = ""; }
[Composable]
component TrackList(List<Track> tracks) {
render { Stack { foreach (var t in tracks) { Row(gap: 2) { Text(t.Title); Slot(t); } } } }
}
[Route("/tracks")]
[AllowAnonymous]
[Render(CSR)]
component Tracks() {
List<Track> tracks = [new Track { Title = "Intro" }, new Track { Title = "Outro" }];
Track? playing = null;
int plays = 0;
render {
Stack(gap: 2) {
TrackList(tracks) { t =>
Button("Play", onPress: () => { playing = t; plays++; }); // the template's parameter
}
foreach (var t in tracks) {
Button("Queue " + t.Title, onPress: () => { playing = t; }); // the loop's row
}
if (playing != null) { Text("Playing " + playing!.Title + " · " + plays); }
}
}
}A value a block handler reads from the render is handed to it when the button is drawn, so it has to be something that can be handed over: a scalar, an enum, a row, a class instance, or a list of one. Anything else is a compile error that names the value and says to pass what the handler needs off it instead.
The lambda takes no parameters, and its body must do something. A handler that only computes a value 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 parameterA 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. The body does not wait for the parent's action,
but a failure is not lost: when that action throws and does not catch it (a refused save, say), the page shows the
failure exactly as it would for a button bound to the action directly, conflict notice included.
An OPTIONAL callback is a nullable delegate with a null default, and it is called the way C# calls one:
onMore?.Invoke(x) runs it when the caller passed one and does nothing when it did not. f.Invoke(x) is the same
call as f(x).
[Composable] component Chip(string label, Action<string>? onRemove = null) {
action Remove() { onRemove?.Invoke(label); }
render { Pressable(onClick: Remove) { Text(label + " ×"); } }
}How does a caller wire up a callback?#
The caller supplies a callback parameter by naming an action — onPick: 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); } }
}
[Route("/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); }
}
[Route("/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#
- class methods — the shared member-body mechanism (
this, implicit members, visibility) - type visibility (public / internal) — why a component, alone among top-level types, takes no
public/internal - subscribe — server-side waits (workflows), distinct from UI reactivity
- action vs. method — what
actiongives you that a plainmethoddoesn't (surprisingly little), and when to reach for each - store — sharing state and actions ACROSS components, rather than within one