dioxus-compose guide

Compose widgets

Widgets in rsx!

The same crate also takes Compose's widget names in rsx!: Column, Text, Button and about thirty more. This path is implemented and draws through the renderer today. Use it when you want Compose's own widgets and a design system rather than CSS.

The shape of an app

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

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 also takes a theme and a window description. On desktop and iOS, Rust's main drives the renderer. On Android and in the browser the platform owns the loop, and android_main! and web_main! declare the entry point instead.

LaunchBuilder::new()
    .with_theme(Theme::unified(DesignSystem::Material3))
    .launch(app);

The widget set

The set is closed on purpose. The renderer interprets exactly these, and a kind it does not recognise produces a ProtocolError event rather than a crash. Every composable, with its props and defaults, is in the composable reference.

GroupComposables
LayoutColumn, Row, Box, ScrollColumn, ScrollRow, Spacer
Text and inputText, TextField, SelectionContainer
ControlsButton, Checkbox, RadioButton, Switch, Slider, Dropdown, DatePicker, TimePicker, ProgressIndicator, Chip, FloatingAction
ContainersCard, Surface, Scaffold, TopAppBar, Divider, Separator, Tooltip, Badge
ListsLazyColumn, LazyRow, LazyGrid
NavigationNavigation, NavigationItem, Tabs
OverlaysDialog, Menu, Sheet, and show_message, which is not part of the tree
PicturesImage, Icon, Canvas
Write dioxus_compose::Box, not Box

The rsx! expansion in dioxus-core 0.7 refers to an unqualified Box<T>. A Compose Box in the glob prelude would shadow std::boxed::Box and break that expansion, so the prelude leaves it out. Use the qualified path.

Modifiers

In Compose a Modifier applies to any composable, and the same holds here. Every widget takes the same thirteen attributes: weight, fill_max_width, fill_max_height, width, height, padding, padding_role, background, shape_role, corner_radius, border_width, border_color and elevation.

rsx! {
    Card {
        fill_max_width: true,
        padding_role: SpaceRole::Md,
        shape_role: ShapeRole::Large,
        background: Paint::Role(ColorRole::SurfaceContainer),
        Text { text: "in a panel" }
    }
}

padding_role and shape_role take the design system's answer; padding and corner_radius take yours. Prefer the role, because density and corner radius differ per design system and a number pins your layout to one of them. An attribute you did not write sends nothing at all.

Events and key consumption

Handlers are ordinary closures, called synchronously on the renderer's UI thread. The diff they cause travels back in the same call, so nothing is queued.

HandlerPayloadOn
on_click()Button, NavigationItem and other clickable widgets
on_value_changeStringTextField
on_submitStringTextField
on_focus_lost()TextField
on_key_downKeyEventTextField
on_changethe control's valueCheckbox, RadioButton, Switch, Slider, Dropdown, DatePicker, TimePicker
on_dismiss()Dialog, Menu, Sheet

A chat input needs Enter to send and Shift+Enter to break the line. Dioxus 0.7 handlers return nothing, so a handler marks the event consumed instead, the same idea as preventDefault() on the web.

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
        }
    }
}
Enter during IME composition

While an input method is composing, the renderer sends no key events to Rust. An Enter pressed mid-composition commits the syllable; it does not submit. That is why the simple condition above is correct and not merely convenient.

The uncontrolled TextField

TextField has no value property. The edit buffer, the selection and the composition belong to the renderer, and Rust hears about them through on_value_change, on_submit and on_focus_lost. Hangul is assembled from jamo as you type, so a value echoed back from Rust mid-syllable would destroy it. Keeping the buffer in the renderer makes that failure impossible rather than rare. The usual way to clear a field from a component is to give it a new identity, as in any Dioxus app.

Lists and streaming

A Column with a for loop is fine for the hundreds of rows an ordinary screen holds. Updating one row sends one change and leaves its siblings alone. For long lists, LazyColumn turns the flow around: the renderer says which range is visible and the Host builds exactly that range plus a buffer. With 10,000 items and 20 visible rows the Host builds 28.

rsx! {
    LazyColumn {
        item_count: messages.len(),
        buffer: 4,
        key_of: move |index| messages()[index].id.clone(),
        item: move |index| rsx! {
            Text { text: messages()[index].body.clone() }
        },
    }
}

Work that blocks (a network stream, a file, a PTY) belongs on a worker thread. A worker writes to a signal, and the Host asks the renderer for a frame itself; requests coalesce into one frame. Text that grows a token at a time is sent as appended tails, coalesced per frame, so a long reply does not resend itself on every token. There is no rsx!-level API for that yet: Host::append_text takes a protocol node id, so component code streams by writing to a signal.

Design systems

Widgets name roles, not colours: a colour role, a type role, a shape role, a spacing role. The renderer resolves them through the design system, so switching to dark mode is one theme change rather than a change to every node. Seven systems are built in: Material3, Cupertino, Fluent, Gnome, Breeze, Deepin and LiquidGlass.

// The same design system everywhere
LaunchBuilder::new().with_theme(Theme::unified(DesignSystem::Material3)).launch(app);

// Follow the host platform; the fallback covers platforms with no look of their own
LaunchBuilder::new().with_theme(Theme::adaptive(DesignSystem::Material3)).launch(app);

Saying nothing follows the host platform, with Material 3 as the fallback, because a desktop app should look like its desktop without configuration. use_design_system() reports which system the renderer chose. The design systems themselves are Compose code and live in compose-rust.

Where state lives

StateOwnerWhy
Application and domain stateRust (signals)It is your program; Dioxus reconciles it into changes.
Text being edited, IME compositionRendererComposition must never round-trip.
Scroll position, focusRendererUI-local, and a frame behind if Rust owned it.
Animation progressRendererRuns on the Compose frame clock.
Whether a dialog, menu or sheet is openRendererThe boolean you pass seeds it; the renderer owns the enter and exit.

Examples

The native-widget samples are in compose-rust/samples/: a calculator, a notepad, a chat, a to-do list, a store and others. The notepad, built for release on macOS, is one 28.76 MB file with no runtime beside it.