dioxus-compose guide

Writing UI

rsx!, widgets and events

If you have written Dioxus before, almost everything here is familiar: components, hooks, signals and rsx! work as they do anywhere else. What is specific to this project is the closed widget schema, the way key events are consumed, and the rule that text composition never crosses the boundary.

The shape of an app

An application is a function returning Element plus a call to launch. The prelude brings in the widgets, the hooks and the macros.

rust
use dioxus_compose::prelude::*;

fn app() -> Element {
    let mut count = use_signal(|| 0i32);

    rsx! {
        Column {
            fill_max_width: true,
            Text { text: format!("count: {}", count()) }
            Button {
                text: "increment",
                on_click: move |_| count += 1,
            }
        }
    }
}

fn main() {
    dioxus_compose::launch(app);
}

launch is shorthand for LaunchBuilder::new().launch(app). The builder carries one setting today, the loop mode:

rust
LaunchBuilder::new()
    .with_mode(LoopMode::Renderer)  // desktop and iOS: Rust main drives the renderer
    .launch(app);

LoopMode::Renderer is the default and the only mode that runs today. LoopMode::Platform, where Android or the browser owns the loop and calls into the Host, is defined in the protocol but has no implementation yet.

The widget set

The schema is closed on purpose. The renderer interprets exactly these eight widgets; a type it does not recognise produces a ProtocolError event rather than a crash. Adding a widget means extending the Rust schema, the Kotlin interpreter and the codegen together.

WidgetPropertiesNotes
Columnfill_max_width, fill_max_height, childrenVertical stack.
Rowfill_max_width, fill_max_height, childrenHorizontal stack.
Boxfill_max_width, fill_max_height, childrenOverlay container. See the naming caveat below.
TexttextTakes anything that converts into String.
TextFieldplaceholder, enabled, multilineUncontrolled. Has no value property, by design.
Buttontext, enabledenabled defaults to true.
Spacerwidth, heightBoth f32, both default to 0.0.
LazyColumnitem_count, buffer, key_of, itemA windowed list. Host side works; the renderer still draws it as a Column. See Lists & streaming.
Write dioxus_compose::Box, not Box

The rsx! expansion in dioxus-core 0.7 refers to an unqualified Box<T> internally. Importing the Compose Box through the glob prelude would shadow std::boxed::Box and break that expansion, so the prelude deliberately leaves it out. Use the qualified path until upstream qualifies its own.

rust, from dioxus-compose/tests/rsx_api.rs
rsx! {
    dioxus_compose::Box {
        fill_max_width: true,
        Text { text: "qualified Box" }
    }
}

Modifiers

Modifiers are serialised as a list of values, for example [Padding(16), FillMaxWidth, Background(argb), Clickable(handler_id)], and the renderer rebuilds them into a Compose Modifier chain. The variants that exist on the wire today are Empty, Padding, FillMaxWidth, FillMaxHeight, Width, Height, Size, Background and Clickable.

What you can actually write today

Only fill_max_width and fill_max_height are exposed as rsx! attributes, on Column, Row and Box. Padding, explicit sizes, background colour and Clickable exist in the protocol and the renderer, but there is no attribute that emits them yet. Treat the remaining variants as the shape the API is growing into, not as something you can call.

Event handlers

Handlers are ordinary Rust closures. They are called synchronously: the renderer calls into the Host on its UI thread, your handler runs, the resulting diff is computed, and the mutation batch travels back in the same call. Nothing is queued.

HandlerPayloadOn
on_click()Button
on_value_changeStringTextField
on_submitStringTextField
on_focus_lost()TextField
on_key_downKeyEventTextField
on_range_requestedRangeRequestLazyColumn, handled inside the component; you pass item and key_of instead

Because handlers run on the UI thread, they must stay cheap. Anything that blocks, a PTY read, a network call, a file, belongs on a Host worker thread. Workers update signals; the Host asks the renderer for a frame on its own. You never call a boundary function yourself.

Event consumption: Enter versus Shift+Enter

A chat input needs Enter to send and Shift+Enter to insert a newline. That requires telling the renderer, synchronously, whether the key was handled, the same idea as preventDefault() on the web or PointerInputChange.consume() in Compose.

Dioxus 0.7 handlers return nothing, so consumption is marked on the event object instead. The boundary reads that mark afterwards and returns it to the renderer, where Modifier.onKeyEvent reports it as handled.

rust, from dioxus-compose/examples/desktop_demo.rs
TextField {
    placeholder: "Write a message",
    multiline: true,
    on_value_change: move |value| draft.set(value),
    on_key_down: move |event: KeyEvent| {
        if event.key() == Key::Enter && !event.shift_key() {
            let message = draft().trim().to_owned();
            if !message.is_empty() {
                messages.write().push(message);
                draft.set(String::new());
            }
            event.consume();   // the renderer's onKeyEvent returns true
        }
    }
}

KeyEvent exposes key(), shift_key(), ctrl_key(), alt_key(), meta_key(), plus consume() and consumed(). The Key enum currently has a single variant, Key::Enter, the only key the M0 schema carries. More keys are a schema addition, not a code change in your app.

Enter during IME composition

While an IME is composing, the renderer does not send key events to the Host at all. An Enter pressed mid-composition means "commit this syllable", not "submit". If that rule were broken, pressing Enter while typing Korean would submit the message with the composing syllable missing. Your handler never sees those keystrokes, which is why the simple condition above is correct rather than merely convenient.

The uncontrolled TextField

TextField has no value property. The edit buffer, the selection and the composition state belong to the renderer. Rust learns about changes through events:

This is not a simplification; it is the central design decision. Round-tripping in-progress text through the Host means that every keystroke of a Korean syllable would leave the renderer, be echoed back, and reset the composition. Hangul is assembled from jamo as you type, so a reset mid-syllable destroys what you were typing. The same applies to Japanese and Chinese input. Keeping the buffer in Kotlin makes the failure impossible rather than rare.

When the Host genuinely has to change the text, clearing an input after send, restoring a draft, it uses an explicit SetText(node_id, text, selection) command, and the renderer defers applying it until any composition finishes. On the Rust side that is Host::set_text, part of the boundary surface rather than something you reach for from a component. In component code, the usual way to clear a field is to give it a new identity, exactly as you would in Dioxus on any other renderer.

Where state lives

StateOwnerWhy
Application and domain stateRust (signals)It is your program. Dioxus reconciles it into mutations.
Text being edited, IME compositionKotlinComposition must never round-trip (see above).
Scroll positionKotlinUI-local; it would be a frame behind if Rust owned it.
FocusKotlinSame reason, and the platform already owns focus.
Animation progressKotlinRuns on the Compose frame clock.