compose-rust guide

Writing UI

Composables, state and events Planned

This page describes the authoring API that arrives with compose-rust 1.0.0 on 2026-10-20. It follows Compose: a composable is a function, state lives in remember, and when state changes only the code that read it runs again.

Planned, not yet in the crate

None of the code on this page compiles against 0.0.x. It shows the intended shape, and the names are not final. The work is tracked in #23. The rules about events, text input and UI-local state already hold today, because the renderer and the boundary enforce them.

A composable is a function

You mark a function with #[composable] and call widgets inside it. The widgets are functions too, with Compose's names: Column, Text, Button. A widget that holds children takes them as a closure.

rust, planned shape (does not compile against 0.0.x)
#[composable]
fn counter() {
    let count = remember(|| mutable_state_of(0));
    Column(Modifier::new().fill_max_width(), || {
        Text(format!("{}", count.get()));
        Button(|| count.set(count.get() + 1), || Text("+1"));
    });
}

fn main() {
    compose_rust::launch(counter);
}

#[composable] inserts the groups that the runtime uses to tell one call site from another. You never write groups or keys by hand. They close correctly through if, match, loops, early return, continue, break, ? and unwinding, because the macro owns them rather than you.

The widget vocabulary is the one in the schema, listed in the widget reference. The authoring API defines no widget or property of its own, so what you can write and what the renderer can draw are the same list.

State and recomposition

A state object records which scopes read it. Writing to it invalidates exactly those scopes, and the next frame runs them again. That is the whole change detection: there is no tree diff.

Positional memory and keys

remember is bound to its call site: the path of groups and the order inside them. When a branch disappears and comes back, or a loop grows and shrinks, each call site keeps its own value and never receives another's.

In a loop, position is sometimes the wrong identity. When items can be reordered, give each one a key, as with Compose's key. A keyed item that changes place is sent as a move rather than a removal and a re-creation, and its remembered values travel with it.

Effects

Three kinds of effect follow Compose's SideEffect, LaunchedEffect and DisposableEffect:

A state write inside an event handler causes a recomposition. A state write during a recomposition is deferred to the next frame, so a composition never feeds itself.

Event handlers

Handlers are ordinary Rust closures, like the first argument of Button above. They run synchronously. The renderer calls into the Host on its UI thread, your handler runs, the runtime recomposes, and the batch of changes travels back in the same call. Nothing is queued.

Because handlers run on the UI thread, they must stay cheap. Anything that blocks, such as a PTY read, a network call or file I/O, belongs on a worker thread. A worker writes to the same state objects, and the Host asks the renderer for a frame by itself. Your code never calls a boundary function.

Consuming a key: Enter versus Shift+Enter

A chat input needs Enter to send and Shift+Enter to insert a newline. The renderer has to learn, inside the same call, whether the key was handled. This is the same idea as preventDefault() on the web and a consumed key event in Compose.

The boundary already carries this today. A key handler's result returns to the renderer as the call's return value, and the renderer reports it to Compose as handled. The authoring API exposes it as a key handler that says whether it consumed the key.

Enter during IME composition

While an IME is composing, the renderer sends no 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, so a plain "Enter without Shift" condition is correct.

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: a debounced notification of the current text, a submitted value, and a lost focus.

The reason is Hangul. A Korean syllable is assembled from jamo as you type. If in-progress text made a round trip through the Host, every keystroke would leave the renderer, come back, and reset the composition, destroying the syllable. Japanese and Chinese input behave the same way. Keeping the buffer in Kotlin makes that failure impossible rather than rare.

When the Host has to change the text, for example to clear an input after sending, it sends an explicit SetText record with the text and the selection. The renderer applies it only after any composition finishes. In 0.0.1 that is Host::set_text.

Animation

The authoring API writes the same animation records the renderer already plays, with Compose's names.

rust, planned shape (does not compile against 0.0.x)
#[composable]
fn banner(visible: bool) {
    let alpha = animate_float_as_state(if visible { 1.0 } else { 0.0 });
    Box(Modifier::new().graphics_layer(|layer| layer.alpha = alpha), || {
        Text("Saved");
    });
}

While the animation plays, the Host does not recompose at all. The values in between live in the renderer, on the Compose frame clock, and the Host never sees them. Without an explicit duration and curve, an animation takes the design system's MotionRole::Standard, so motion follows the platform. A duration and a curve of your own are the escape hatch.

Where state lives

Application state is yours, in Rust. UI-local state is the renderer's, in Kotlin, and the recomposition runtime does not move it into Rust. This already holds today.

StateOwnerWhy
Application and domain stateRustIt is your program.
Text being edited, IME compositionKotlinComposition must never make a round trip (see above).
Scroll positionKotlinIt would be a frame behind if Rust owned it.
FocusKotlinThe platform already owns focus.
Animation progressKotlinIt runs on the Compose frame clock.
Whether a dialog, menu, dropdown or sheet is openKotlinThe value you pass seeds it, and the renderer owns the enter and the exit, so the animation never makes a round trip.
The position a slider drag is passing throughKotlinFollowing a finger should not cost a boundary call.
The app zoom levelKotlinIt is saved per application by the renderer. See Architecture.