dioxus-compose guide

Lists & streaming

Long lists and streaming text

A chat client is a very long list plus text that arrives a token at a time. Both now exist on the Rust side with tests behind them, and both are only half the story until the Compose interpreter catches up. This page says precisely which half you can rely on.

Where these stand

LazyColumn (FR-8) and AppendText (FR-9) are Agreed in the SPEC and implemented in the Host: both have passing acceptance tests in dioxus-compose/tests/. The Compose interpreter is being finished in parallel; until it lands, see the renderer gap below for what that means on screen.

A plain list

A Column with a for loop over your data is correct and is fine for the hundreds of rows that fit an ordinary screen with some scrollback. It does not virtualise: every item becomes a real node on both sides.

rust, the pattern used by the desktop demo
rsx! {
    Column {
        fill_max_width: true,
        for message in messages() {
            Text { text: message }
        }
    }
}

Updating one message sends exactly one SetProp mutation and does not recompose its siblings, a tested requirement (FR-4), not an aspiration. The cost of this pattern is the node count, not the update path.

LazyColumn windowing Host side

A plain tree diff cannot keep a virtualised list virtual: the Host would have to describe items it never built in order for the renderer to know they exist. So for lazy lists the flow inverts.

  1. The Host declares the total item count and a stable key per item.
  2. The renderer decides which range is visible and asks for it with a RangeRequested event (event tag 7, 24 bytes).
  3. The Host materialises subtrees for that range plus a buffer on each side, and nothing else.

Each item is wrapped in a Box node carrying an item_key string, which is what the renderer hands to Compose's LazyColumn as the item key. Scroll position and item identity stay in the renderer; the data stays in the Host. Scrolling back to an item you have already seen re-materialises an identical subtree.

rust, the component as defined in dioxus-compose/src/widgets.rs
rsx! {
    LazyColumn {
        item_count: messages.len(),
        buffer: 4,                                     // items kept on each side; default 4
        key_of: move |index| messages()[index].id.clone(),  // optional; defaults to the index
        item: move |index| rsx! {
            Text { text: messages()[index].body.clone() }
        },
    }
}

item and key_of are Callbacks, so a closure works directly. item_count is a usize; the wire carries it as an i64. Only item_count and item are required.

The acceptance criterion is concrete, and it passes: with 10,000 items and a request for 20 visible rows with a buffer of 4, the Host materialises 28 items, proportional to the window, not to the list. The test is fr8_node_count_is_proportional_to_the_window.

The renderer gap

The Kotlin interpreter currently draws a LazyColumn node as a Compose Column and never sends a RangeRequested event. The protocol, the property and the event decoder are all in place, what is missing is the Compose LazyColumn that drives them. Until that lands, a LazyColumn renders its initial window and stays there. Write against the component now if you like; do not ship a 10,000-item screen on it yet.

Streaming text Host side

An LLM response arrives token by token. Replacing the whole text property on every token re-sends the entire message each time, and the message grows, so the cost grows quadratically. The answer is an AppendText command on a Text node (mutation tag 8, 16 bytes) that carries only the new tail.

The Host coalesces: tokens that arrive between two frames merge into a single AppendText record per node, flushed in render_frame. A token never costs a batch of its own, and the accumulation buffers are reused so steady-state streaming does not allocate.

rust: Host::append_text, the whole streaming API today
// Queue a tail for the next frame. Repeated calls coalesce into one record
// per node and into a single frame request.
host.append_text(node_id, " token");
There is no component-level streaming API yet

append_text takes a protocol node_id, and component code has no good way to obtain one. It is a boundary-level facility that the tests and the embedder drive, not something to call from inside a component. Until an rsx!-level affordance exists, stream by writing to a signal and letting the diff send a SetProp: correct for short answers, and quadratic for long ones.

Measured on the Host side: a 36 KB message stays under a 64-byte batch per token, and the p99 streaming frame is 125 ns. The full criterion, 100 appends per second with scrolling and typing staying smooth, is an end-to-end claim and has to wait for the renderer.

Streaming from a worker thread

This part is not speculative, and it matters more than either feature above. Domain work must not run on the UI thread, the thread that also runs your components and the whole boundary. PTY reads, HTTP, LLM streaming and file I/O belong on Host worker threads.

A worker's only job, as far as the UI is concerned, is to write to a signal. Waking the renderer is internal: the Host requests a frame for you, and repeated requests coalesce into a single render_frame call inside the Compose frame clock. User code never calls a boundary function, and there is no queue between the two sides to fill up.

One consequence worth remembering

Since a handler runs inside the renderer's call into the Host, a frame requested from inside a handler is deferred until that call returns. Re-entering the renderer from a handler is not allowed. Change state and let the frame happen.

The frame budget

The performance target is a comparison, not a number in isolation: the same screen written directly in Kotlin and Compose is the baseline, and this stack is allowed at most 10% more frame time than that, against an 8.33 ms budget on a 120 Hz display. A few of the budgets that shape how you write code:

ItemBudget (p99, release)
Host work: handler + diff + batch encoding≤ 0.5 ms for ordinary interaction, ≤ 1 ms for a streaming frame
One boundary call≤ 100 ns on desktop and iOS, ≤ 200 ns on Android
Applying a batch in the renderer≤ 0.3 ms per 100 mutations
Input to pixelsSame frame count as the baseline; zero extra frames of latency
Dropped framesZero while streaming 100 tokens/s and scrolling a 10,000-message conversation

Allocation is measured as a trend rather than an absolute: Dioxus allocates internally while diffing and dispatching (99 allocations per click, measured 2026-09-20), and Rust has no GC, so those allocations do not produce pauses. What matters is that repeating the same interaction does not increase the count. Growth means a leak or a cache that stopped working, and is investigated as a bug.