hypereffect

Signals

Signals are the state an interface reads and writes: one JSON object, shared by the server and the renderer. Controls bind to it in both directions, and attributes follow it as it changes. The idea, and the name, come from Datastar.

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.

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"}} for user.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.