dioxus-compose guide

Forms and events

Handlers are Dioxus handlers

You write onclick, oninput, onchange and onsubmit as you would for the web. A click lands on the element under the pointer and bubbles; a form does what a browser's form does. Fields are uncontrolled, so typing never makes a round trip through Rust.

Clicks

A click goes to the topmost element at that point, as decided by paint order and z-index, and bubbles to the nearest ancestor that listens. Then the browser's defaults follow:

A checkbox changes before the click handlers run, so they see its new state, and it changes back if one of them calls prevent_default(). On a link, prevent_default() keeps the page where it is, as in samples/hello.

Fields are uncontrolled

The renderer owns a field's text while the user edits it. Only committed text reaches Rust, through oninput, never text an input method is still composing. This is the rule that keeps Korean, Japanese and Chinese input working: a syllable is built from several keystrokes, and echoing each one back from Rust would reset it.

samples/settings shows the pattern. The fields start from what was saved. What is typed goes into a draft that nothing renders from, so typing does not render the form again and never writes a field's text back while it is being edited.

samples/settings/src/lib.rs, trimmed
// What was last saved. The fields start from it.
let mut saved = use_signal(Profile::initial);
// What has been typed since. Nothing renders from it.
let mut draft = use_signal(Profile::initial);
let profile = saved();

rsx! {
    form {
        id: "settings",
        class: "settings",
        // Pressing Enter in a field submits the form.
        onsubmit: move |event| {
            event.prevent_default();
            saved.set(draft());
        },
        input {
            id: "name",
            class: "control",
            r#type: "text",
            value: "{profile.name}",
            placeholder: "Your name",
            oninput: move |event| {
                draft.write().name = event.value();
            },
        }
        textarea {
            id: "bio",
            class: "control",
            value: "{profile.bio}",
            oninput: move |event| {
                draft.write().bio = event.value();
            },
        }
    }
}

A field holds what was committed until the app sets its value again, and that value is what the form submits. Setting value is how the app replaces the text, for example to clear a field after sending, as samples/chat and samples/todo do.

Selects, checkboxes and radio buttons

samples/settings/src/lib.rs, trimmed
select {
    id: "theme",
    class: "control",
    value: "{profile.theme}",
    onchange: move |event| {
        draft.write().theme = event.value();
    },
    option { value: "light", "Light" }
    option { value: "dark", "Dark" }
    option { value: "system", "Match the system" }
}

input {
    id: "weekly",
    r#type: "checkbox",
    checked: weekly(),
    onclick: move |_| weekly.toggle(),
}

for (index, choice) in DENSITIES.into_iter().enumerate() {
    label { key: "{choice}", class: "check",
        input {
            id: "density-{index}",
            r#type: "radio",
            name: "density",
            value: "{choice}",
            checked: density() == choice,
            onclick: move |_| density.set(choice),
        }
        "{choice}"
    }
}

A select is one field whose options the renderer lists in its own dropdown; the options are not drawn in the page. Picking one sends input and then change, as a browser does. Radio buttons with the same name form a group, so picking one clears the others.

The input types text, password, email, number, search, tel, url, checkbox and radio, and textarea and select, each arrive in the display list as a field with its kind, its starting value, its placeholder and the box its text goes in.

What the renderer calls

Each user action has one call on HtmlDom. The renderer will make them once it draws HTML screens. Today tests and embedders make them, and the examples' tests go through exactly these calls.

CallThe userWhat reaches the app
click(x, y)clicks a pointonclick, then the browser default
input(node, value)commits text in a fieldoninput
change(node, value)is done with a fieldonchange
select(node, index)picks an optiononinput, onchange
check(node, checked)ticks a checkbox or picks a radioonclick, oninput, onchange
submit(node)presses Enter in a single-line fieldthe submit button's onclick if there is one, then onsubmit
samples/settings/tests/settings.rs, trimmed
assert_eq!(dom.input(name, "Grace Hopper"), Some(name));
assert_eq!(dom.select(theme, 1), Some(theme));
relayout(&mut dom);
// Typing renders nothing: the field's text stays the renderer's.
assert_eq!(entry(&dom, "name").input.as_ref().unwrap().value, "Ada Lovelace");

assert_eq!(dom.submit(name), Some(form));
relayout(&mut dom);
assert_eq!(text_of(entry(&dom, "status")), "Saved");
assert_eq!(entry(&dom, "name").input.as_ref().unwrap().value, "Grace Hopper");

Handlers run inside these calls. render applies what they changed, and the next layout shows it.

Keyed lists

Give each row a key, as in any Dioxus app. In samples/todo moving an item up moves its row rather than rewriting the text of every row, ticking an item changes only that row, and adding one inserts one. The plan's diff reports exactly those inserts, removals and moves, which is what keeps an update small at the renderer.

samples/todo/src/lib.rs, trimmed
ul { id: "todos", class: "todos",
    for (index, todo) in items.into_iter().enumerate() {
        li {
            key: "{todo.id}",
            id: "todo-{todo.id}",
            class: if todo.done { "todo done" } else { "todo" },
            input {
                id: "toggle-{todo.id}",
                r#type: "checkbox",
                checked: todo.done,
                onclick: move |_| todos.toggle(todo.id),
            }
            span { class: "title", "{todo.title}" }
            button {
                id: "up-{todo.id}",
                disabled: index == 0,
                onclick: move |_| todos.shift(todo.id, Direction::Up),
                "Up"
            }
        }
    }
}

State that is shared

The list in samples/todo and the conversation in samples/chat live in a struct of signals that the app provides as context, so a child component such as the row that adds an item reads it with use_context. This is ordinary Dioxus, and nothing about it is specific to this renderer.

Not yet