Docs

Input bindings

Bind form controls to writable RSJS signals and SharedState fields.

Open Markdown

A binding synchronizes one form-control DOM property with one writable RSJS cell. This page covers binding text inputs, checkboxes, select values, and SharedState fields.

Binding text inputs

Use bind_value for an input element's string value property. That cell is an owner-local Signal or a field handle you acquire from typed SharedState. User edits flow into the cell, and later cell writes flow back to the DOM.

src/components/search_box.rs
let query = signal(String::new());

html_sync! {
    <label class="grid gap-2">
        "Search"
        <input type="search" bind_value={query} />
    </label>
    <p>"Current query: "{query.get()}</p>
}

For an input, the server output includes its initial value and a hydration marker with a shape similar to:

output.html
<input data-rsjs-input-value="..." value="">

The data-rsjs-* name and ID are compiler-owned implementation details. Do not author or query them from application code.

Good to know: the runtime binding surface also targets a textarea's value property. The current SSR emitter does not yet synthesize the textarea's initial text content from the cell seed. Treat first-paint/no-JS textarea parity as a current limitation.

Use a binding when one DOM property maps directly to one writable cell. Use a named typed event handler when the edit also needs parsing, validation, normalization, or multiple writes. Never use both mechanisms to perform the same write.

Booleans follow the same one-property rule under a different attribute name.

Binding checkboxes

bind_checked expects a writable boolean cell and mirrors the control's checked property.

src/components/newsletter_optin.rs
let subscribed = signal(false);

html_sync! {
    <label class="inline-flex items-center gap-2">
        <input type="checkbox" bind_checked={subscribed} />
        "Email me updates"
    </label>

    {if subscribed.get() {
        html_sync! { <p>"Subscribed"</p> }
    }}
}

A <select> binds a string rather than a boolean, and it carries one extra name.

Binding select values

Both bind_value and bind_selected target the owning <select> element's string value: the selected option's value.

src/components/plan_picker.rs
let plan = signal("starter".to_string());

html_sync! {
    <label class="grid gap-2">
        "Plan"
        <select bind_selected={plan}>
            <option value="starter">"Starter"</option>
            <option value="team">"Team"</option>
            <option value="enterprise">"Enterprise"</option>
        </select>
    </label>

    <p>"Selected plan: "{plan.get()}</p>
}

bind_selected does not bind an individual <option> element's boolean selected property. Prefer bind_value when consistent value-binding syntax across controls is clearer. Use bind_selected when the select-specific name communicates intent better.

Good to know: the current SSR emitter writes the bound select seed through a generic value attribute. HTML does not use that attribute to choose an option during parsing. If the initial choice is not already the markup's selected or default option, the correct value appears when the owner activates. Exact no-JS first-paint select parity is a current emitter gap.

Each control above binds an owner-local signal; a provider field binds the same way.

Binding SharedState fields

Bind a writable field from the nearest typed provider directly:

src/components/preferences_search.rs
let preferences = rsjs::shared::<PreferencesState>();
let search = preferences.search();

html_sync! {
    <input type="search" bind_value={search} />
}

Every independent owner under that provider occurrence observes the same field cell. A computed value remains read-only and cannot be a bind_* target.

An edit that does more than write one cell needs a handler instead.

Handling derived state and side effects

Use on_input or on_change instead of a binding when one edit performs more than direct synchronization.

src/components/search_form.rs
let query = signal(String::new());
let touched = signal(false);

let update_query = rsjs::event_handler(
    |event: rsjs::InputEvent<rsjs::elem::Input>| {
        query.set(event.current_value());
        touched.set(true);
    },
);

html_sync! {
    <label class="grid gap-2">
        "Search"
        <input type="search" value={query.get()} on_input={update_query()} />
    </label>

    {if touched.get() && !query.is_non_empty() {
        html_sync! { <p role="alert">"Enter a search term."</p> }
    }}
}

For several related writes, wrap the handler work in rsjs::batch(...). All values change synchronously, and DOM and subscriber work flushes once.

For numeric or structured values, keep a local browser input as a string at the boundary. Parse it in a typed handler or on submit. Shared fields with a pinned fixed-width representation validate and coerce their input according to that field's schema.

The next section covers edits that land before the owner activates.

What happens to edits before hydration?

A user may type, check a box, or choose an option before a delayed owner activates. On the owner's first mount, RSJS compares the live control property with its immutable SSR baseline:

Within one owner, all controls reconcile before the first DOM commit. An untouched sibling bound to the same cell therefore cannot immediately overwrite a dirty control.

Reconciled or not, the browser still owns what a native form posts.

Submitting a form

Bindings pair with native forms. Let the browser own submission when a normal POST is enough, and use RSJS state for optimistic UI or inline validation.

src/components/newsletter_form.rs
let email = signal(String::new());
let terms = signal(false);
let submitting = signal(false);
let mark_submitting = rsjs::event_handler(
    |_: rsjs::SubmitEvent<rsjs::elem::Form>| submitting.set(true),
);

html_sync! {
    <form method="post" action="/newsletter" on_submit={mark_submitting()}>
        <input type="email" name="email" bind_value={email} required />

        <label>
            <input type="checkbox" name="terms" bind_checked={terms} required />
            "I accept the terms"
        </label>

        <button type="submit" disabled={submitting.get()}>
            {if submitting.get() { "Joining..." } else { "Join" }}
        </button>
    </form>
}

Keep name attributes on posted fields: RSJS state does not replace native form serialization. Use a hidden input when the server needs a derived value that does not map one-to-one to a visible control.

The table below collects the rules behind these patterns.

What rules do bindings follow?

RuleReason
Bind one DOM property to one writable cell per elementCompeting bindings create ambiguous ownership.
bind_value writes stringsParse or normalize local numeric/structured values in a handler.
bind_checked writes booleansIt mirrors the control's checked property.
bind_selected writes the select's string valueIt does not bind option.selected.
Keep name attributes on posted fieldsNative form submission reads the DOM, not RSJS state directly.
Use handlers for side effectsBindings stay direct property synchronization.

Next steps

Search

Type at least 2 characters