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.
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.
#[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.
- Unchanged calls are skipped. Inside a scope that runs again, a composable call whose arguments equal last time's does not run its body.
- Writes in one frame coalesce. Several writes before the next frame cause one recomposition, not one each.
- One changed text sends one record. Changing the content of one
Textsends a singleSetProp, and its siblings and parent do not run.
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:
- an effect that runs once after each composition is applied;
- an effect that restarts when its keys change and is cancelled when it leaves the composition;
- an effect that runs a cleanup once when it leaves the composition.
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.
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.
Modifier::graphics_layertakes rotation, scale, translation, origin and alpha.animate_float_as_state,animate_color_as_state,animate_offset_as_stateand the otheranimate_*_as_statefunctions send one start record when the target changes. The renderer plays the animation, and the value they return is the target.
#[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.
| State | Owner | Why |
|---|---|---|
| Application and domain state | Rust | It is your program. |
| Text being edited, IME composition | Kotlin | Composition must never make a round trip (see above). |
| Scroll position | Kotlin | It would be a frame behind if Rust owned it. |
| Focus | Kotlin | The platform already owns focus. |
| Animation progress | Kotlin | It runs on the Compose frame clock. |
| Whether a dialog, menu, dropdown or sheet is open | Kotlin | The 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 through | Kotlin | Following a finger should not cost a boundary call. |
| The app zoom level | Kotlin | It is saved per application by the renderer. See Architecture. |