A live example
The input and the checkbox are bound to signals, and the greeting and the badge are expressions that read them. Type in the field, tick the box, or press Clear, and watch the signals below the rendering.
signal {"user":{"name":"Ada"},"_shout":false}
element {"id":"form","type":"column","gap":"small","align":"start"}
element {"id":"name","type":"input","parent":"form","label":"Name","bind":"user.name"}
element {"id":"shout","type":"checkbox","parent":"form","label":"Shout","bind":"_shout"}
element {"id":"greeting","type":"text","parent":"form","variant":"h3","text":{"cel":"s.user.name == '' ? 'Who are you?' : 'Hello, ' + (s._shout ? s.user.name.upperAscii() + '!' : s.user.name)"}}
element {"id":"length","type":"badge","parent":"form","text":{"cel":"string(size(s.user.name)) + ' characters'"},"visible":{"cel":"s.user.name != ''"}}
element {"id":"clear","type":"button","parent":"form","variant":"plain","label":"Clear","onPress":{"patch":{"user":{"name":""}}}}
Nothing here talks to a server: the binding, the patch from Clear and the updates all happen in the renderer.
Changing signals
A session starts with the signals sent by the renderer in its hello, usually
{}. After that they change in three ways, all as
JSON Merge Patch: objects merge
recursively, other values replace, and null deletes a key.
- The server sends a
signalmessage. - The user changes a control with
bind. -
A handler applies a
patch, or sets an indicator.
signal {"user":{"name":"Ada","unread":3}}
signal {"user":{"unread":4}} # name is kept
signal {"user":{"unread":null}} # unread is deleted
Arrays are values: a patch replaces an array as a whole.
Local signals
A key that begins with _, at any depth, is local. Local signals are never
sent to the server, neither in hello nor with actions. They hold state that
only matters to the renderer, such as whether a dialog is open or which tab is selected.
The server may still write them.
Signal paths
The bind attribute and handler indicators name a place in the signals with a
dot-separated path, such as "user.name". Each segment matches
[A-Za-z_][A-Za-z0-9_]*. A path whose first segment begins with
_ is local.
Two-way binding
A control with bind — input,
checkbox,
select,
slider or tabs —
is tied to the value at its path in both directions.
- From the signals to the control
- The control shows the value at its path. Whenever that value changes, whether from the server, a handler or another control bound to the same path, the control shows the new value, even if it has focus. If the value is missing or of the wrong type, the control shows its empty state.
- From the control to the signals
-
When the user changes the control, the renderer writes the new value at the path, as the
patch
{"user":{"name":"Grace"}}foruser.name. Missing objects along the path are created. The value has the type the component describes: a string for an input, a bool for a checkbox, a number for a slider.
Binding is local: the server learns the value when an action is sent, since actions carry
the signals. To react to a change as it happens, give the control an
onChange handler with an action.
element {"id":"email","type":"input","bind":"form.email","onChange":{"action":"check-email"}}
# renderer → server, after the user types and leaves the field
action {"name":"check-email","signals":{"form":{"email":"ada@example.com"}}}
Attributes follow signals
Any attribute given as an expression is a function of the
signals. When the signals change, by any of the means above, the renderer re-evaluates the
expressions that read the changed values and updates what they affect, without anything
being re-sent. The server can change a whole interface with one small
signal message:
signal {"unread":3}
element {"id":"inbox","type":"button","label":{"cel":"'Inbox (' + string(s.unread) + ')'"},
"variant":{"cel":"s.unread > 0 ? 'primary' : 'secondary'"}}
signal {"unread":0} # the label becomes "Inbox (0)" and the button secondary
Renderers should only redraw what changed. An expression reads specific signals, such as
s.unread or s.user.name, so a renderer can record which ones
each attribute read the last time it was evaluated, and skip it when a patch changes none
of them. Only the branches that ran count: s.a ? s.b : s.c depends on
a and b while s.a is true, and on
a and c once it becomes false. Bound controls work the same way:
a control is updated when the value at its path changes. The exact rules are in the
specification.
Use elements for structure, what is on the screen, and signals for values that change while the structure stays the same: a count, a status, a validation message, whether something is visible. Keep signals small: they are sent with every action.