Quill docs
Signals & reactive state
A signal is a single piece of reactive state. Quill's Signal<T> is unusual: the very same signal code an author writes is server-inert and client-reactive. On the server (this Rust crate, qquill-signal) a signal is just an initial value plus a stable id — it renders that value into static HTML and can serialize it into an island's props. On the client (the JavaScript runtime) the signal becomes a real fine-grained reactive primitive that drives DOM updates. This page covers Signal<T>, the SignalValue trait, the inert computed/effect helpers, how a signal becomes props via props_entry, the data-q-bind markers, and how the client recreates the signal from props.
The server-inert / client-reactive idea#
You write signal code once. On the server it is a plain holder: a Signal<T> carries an initial value and a stable id, renders the initial value into static HTML marked with data-q-bind, and can serialize that value into an island's props JSON. There is no reactive graph on the server — nothing changes, there is nothing to react to. On the client, the islands runtime reads the props sidecar, re-creates the signal from the serialized value, and wires fine-grained DOM updates onto the data-q-bind markers. The Rust crate ships no JavaScript at all; it only produces the static markers and props the JS side consumes.
Creating signals#
There are two constructors. Use signal(initial) for an auto-assigned stable id (s0, s1, s2, ...), and signal_id(id, initial) when the id must be referenced by name elsewhere (e.g. a hand-written client behavior, or to match a binding marker).
use qquill_signal::{signal, signal_id, reset_ids};
reset_ids(); // start of a render pass
let count = signal(0i32); // id == "s0"
let name = signal("Ada"); // id == "s1"
let open = signal_id("open", false); // explicit id == "open"- signal(initial) -> Signal<T>
- Creates a signal with an auto-assigned id (s0, s1, ...). Ids are drawn from a per-thread monotonic counter.
- signal_id(id, initial) -> Signal<T>
- Creates a signal with an explicit, author-chosen id. The id accepts anything Into<Cow<'static, str>>.
- reset_ids()
- Resets the auto-id counter to 0. Call once at the start of a render pass so ids are stable and minimal across identical renders.
Reading a signal's value#
On the server a signal is just a holder, so the accessors are plain reads of the initial value.
| Method | Returns | Notes |
|---|---|---|
| id() | &str | The stable id: the data-q-bind value and the props key the client reads. |
| get() | &T | Borrow the initial (server-authoritative) value. |
| into_inner() | T | Consume the signal, returning its initial value. |
| text_value() | String | The inert text form of the value (requires T: SignalValue). |
| to_json() | JsonValue | The initial value as a JSON value (requires T: SignalValue). |
SignalValue: what a signal can hold#
A signal value must render two ways: to inert text (for SSR) and to JSON (to ride in island props). That is the SignalValue trait. The text form is what no-JS users see; the JSON form is what the client re-instantiates the signal from.
pub trait SignalValue {
fn to_text(&self) -> String; // inert SSR text
fn to_json(&self) -> JsonValue; // rides in island props
}Implementations ship for the common scalars and string types: all the integer types (i8..i64, isize, u8..u64, usize), bool, String, and &'static str. Numbers serialize as JSON numbers, bools as JSON booleans, and strings as JSON strings.
Rendering a signal into the page#
A signal renders into the DOM as a bound text node the client can later find and update. text_node() returns a <span data-q-bind="<id>"> whose content is the initial value (HTML-escaped by the view layer). No-JS users see the value; the client finds the span by its data-q-bind and reactively updates its textContent.
Request
use qquill_signal::{signal, reset_ids};
use qquill_view::render;
reset_ids();
let count = signal(0i32);
assert_eq!(count.id(), "s0");
assert_eq!(count.text_value(), "0");
render(&count.text_node())Response
<span data-q-bind="s0">0</span>Values are escaped. A signal holding <b>&"hi"</b> renders the marker intact but escapes the content, so no raw markup reaches the page:
Request
let s = signal_id("x", String::from("<b>&\"hi\"</b>"));
render(&s.text_node())Response
<span data-q-bind="x"><b>&"hi"</b></span>Output
The data-q-bind marker survives; the value is fully HTML-escaped (no literal <b> appears).Binding an attribute instead of text#
Sometimes you want a signal to drive an element attribute (e.g. aria-expanded) rather than text content. bind_attr_marker(signal_id, attr) returns the (data-q-bind, "<attr>:<id>") attribute pair to add to the element. The element keeps its initial inert attribute value for no-JS users; the runtime updates that attribute on change.
Request
use qquill_signal::{signal_id, bind_attr_marker};
use qquill_view::node::Node;
let open = signal_id("open", false);
let (name, value) = bind_attr_marker(open.id(), "aria-expanded");
// name == "data-q-bind", value == "aria-expanded:open"
Node::element("button")
.attr("aria-expanded", "false")
.attr(name, value);Response
<button aria-expanded="false" data-q-bind="aria-expanded:open"></button>From signals to island props (props_entry)#
The bridge from a server signal to client state is props_entry(). It returns the (key, value) pair — the signal's id as the key, its initial value as JSON — that you fold into the island's props object. Build the object with JsonValue::object([...]), which sorts keys deterministically (BTreeMap), and call to_json() for the string you pass to island(...).
Request
use qquill_signal::{signal_id, JsonValue};
let count = signal_id("count", 5u32);
let (k, v) = count.props_entry();
// k == "count", v.to_json() == "5"
let open = signal_id("open", false);
let obj = JsonValue::object([open.props_entry(), count.props_entry()]);
obj.to_json()Response
{"count":5,"open":false}Output
Keys are emitted in deterministic (sorted) order regardless of insertion order, so the JSON is content-hashable / build-twice byte-equal.The id you choose is load-bearing: it is both the props key the client reads AND the data-q-bind value on the markers the client updates. Use signal_id with a name your client behavior expects (the shipped counter behavior, for example, reads props.start and binds the data-q-bind="count" node).
computed and effect on the server (inert)#
computed and effect exist in the Rust crate so the same authoring code compiles for both server and client — but on the server they are inert. computed(f) eagerly evaluates f exactly once and returns the plain value; there is no reactive graph. effect(f) is a no-op — it takes the closure but never runs it, because effects touch the live DOM and schedule microtasks, of which there are none on the server.
Request
use qquill_signal::{signal_id, computed, effect};
let base = signal_id("n", 3i32);
let doubled = computed(|| base.get() * 2);
// doubled == 6 (a plain value, evaluated once)
let mut ran = false;
effect(|| ran = true);
// ran == false (effect never runs on the server)Response
doubled == 6
ran == falseThe JSON value type#
qquill-signal ships a tiny zero-dependency JsonValue — just enough to serialize signal initial values into props. Objects are backed by a BTreeMap so key order is deterministic. Constructors: JsonValue::int(i64), JsonValue::uint(u64), JsonValue::string(impl Into<String>), JsonValue::object(entries), plus the Null, Bool, Number, Str, Array variants. to_json() serializes to a compact deterministic string.
Client recreation of the signal#
On the client, hydration reverses the server steps. The runtime finds the island boundary, reads its sidecar <script type="application/qq" data-q-for="<id>"> and JSON.parses the body (which reverses the < / & escapes automatically), and passes the parsed object to the behavior factory as ctx.props. The behavior creates a real client signal from that initial value with ctx.signal(...), then registers an effect that writes the value into the existing data-q-bind node — updating only that node, never re-rendering the server tree.
AUTHOR (Rust) CLIENT (JS, on hydrate)
------------- -----------------------
signal_id("count", 0)
.text_node() ──renders──► <span data-q-bind="count">0</span>
.props_entry() ─folds into─► sidecar {"count":0}
│ JSON.parse(sidecar)
▼
var count = ctx.signal(props.start);
ctx.effect(() => out.textContent = String(count()));The same id ("count") threads through all three: the props key, the data-q-bind marker, and the node the client effect updates. That shared id is the entire wiring contract between server and client.