Hedronite · Frontend Dev · Rust · Fri 2026-10-16

Dioxus 0.7 by contrast with Leptos components, signals and the virtual DOM

Know which code runs again, keep state where reruns cannot reset it, and test the UI without a browser.

Lesson Class: Dev (Rust UI · Dioxus, contrast with Leptos)
Tested with: dioxus 0.7.10 · dioxus-ssr 0.7.10 · dioxus-html 0.7.10 · cargo 1.99.0 · macOS
Lab: crate/ · cargo test (6 tests) · headless click + mutation capture
Source: Dioxus 0.7 guide and docs.rs · Leptos book and README · The Rust Programming Language, ch. 4.1 and 13.1
The Function Runs Again
A signal write reruns every component that read it; equal props let children skip.
State Lives in Hooks
use_signal keeps the value between runs, found by call order, so never call hooks conditionally.
Diff to Mutations
Templates skip static parts; one click became one SetText for the renderer.
Rerun the function; send the difference.

The problem

You are building a small status widget in Rust. It shows a label, a count of acknowledged jobs, and a +1 button. Rust has two popular frameworks for this, Dioxus and Leptos. Both advertise components (functions that build a piece of the interface), a macro that looks like HTML, and "signals" (values that tell the interface when they change). It is natural to assume they work the same way, so that what you learn in one carries over to the other.

It does not carry over, and the difference decides where your code runs again. Put a log line, an expensive computation, or a plain let in the body of a component: in one framework that body executes once, when the component is created, and in the other it executes again after every state change. The log line that printed once now prints on every click, and the let you meant as one-time setup is recomputed on each update.

You also want the widget covered by cargo test, without launching a browser, a window, or a web server. That is only possible if you know what the framework outputs when it renders and how a click reaches your code. The counter below is built in Dioxus 0.7, instrumented to count which functions rerun after a click, and tested headless, with Leptos described alongside wherever the two designs part ways.

The idea, in plain words

In Dioxus, a component is a function that returns a description of the UI rather than the UI itself. State lives in signals, which are values the framework stores outside your function and hands back to you on every run. When a signal that the component read during its run changes, Dioxus runs the whole function again and compares the new description with the previous one. It then sends the renderer (the part that talks to the real screen) only the edits that turn the old picture into the new one. Leptos works differently: a component function runs once, and each signal is wired directly to the DOM node (the browser's in-memory object for one element) that displays it, so a change updates that node and no component function runs again.

Three core concepts

All the code below was run on macOS with rustc and cargo 1.99.0, dioxus 0.7.10, dioxus-ssr 0.7.10 and dioxus-html 0.7.10, in a crate using the 2024 edition. The Leptos documents quoted describe Leptos 0.8.22; no Leptos code was built or measured here.

A. Components, rsx! and props

Definition. A component is a function that returns an Element, which is Dioxus's description of a piece of UI. If you want to use one component from inside another, you annotate it with #[component]. That attribute macro gives the rsx! macro the metadata it needs, so function arguments become fields in the component's properties, usually called props. Props are the inputs a parent passes to a child. Here #[component] generates a struct called CounterProps from the arguments of Counter.

rsx! is a procedural macro, meaning a compiler plugin that transforms Rust tokens into Rust code. Its syntax is designed to look like a blend of HTML and SwiftUI: elements with attributes written as name: value, children nested inside braces, and components used as if they were elements.

Why it matters. Props give the runtime a value it can compare, and that comparison is how it avoids rerunning children whose inputs have not changed. Props must implement two traits. PartialEq lets the runtime ask whether the new props equal the old ones, which decides whether the child must rerun when its parent reruns. The guide gives the reason: components are called by other components, so a small change near the top of the tree can cascade into work all the way down, and the equality check is how the runtime keeps that cascade small. Clone is required because the props object is cloned on each render, which also enforces what the guide calls unidirectional data flow: a child holds its own copy of its inputs and cannot modify the parent's.

Skipping work by remembering previous inputs and reusing the previous result when they match is called memoization. Also, components are not called like regular functions: when you write Badge { text: "beta" } inside rsx!, Dioxus creates a new instance of the component that controls its own state and lifecycle.

Worked example. The crate's Cargo.toml lists the framework and the renderer used in tests:

[package]
name = "dioxus_contrast"
version = "0.1.0"
edition = "2024"

[dependencies]
dioxus = { version = "=0.7.10", default-features = false, features = ["macro", "html", "signals", "hooks"] }
dioxus-ssr = "=0.7.10"

[dev-dependencies]
# Only the tests need this: it lets a test build a click event without a browser.
dioxus-html = { version = "=0.7.10", features = ["serialize"] }

A dev-dependency is a crate compiled only for tests, examples and benchmarks, not for anyone who uses your library. Only the macro, html, signals and hooks features of dioxus are on, and no platform feature such as web or desktop, so the only renderer in play is dioxus-ssr, which turns a virtual tree into an HTML string. Here is the top of src/lib.rs with the two working components; the rest of the file holds the deliberately broken components shown in the mistakes below:

use std::sync::atomic::{AtomicUsize, Ordering};

use dioxus::prelude::*;

/// How many times each component function has run (for the tests).
pub static COUNTER_RUNS: AtomicUsize = AtomicUsize::new(0);
pub static BADGE_RUNS: AtomicUsize = AtomicUsize::new(0);
pub static FORGETFUL_RUNS: AtomicUsize = AtomicUsize::new(0);

/// A component with two props and one piece of local state.
#[component]
pub fn Counter(label: String, start: i32) -> Element {
    COUNTER_RUNS.fetch_add(1, Ordering::SeqCst);
    let mut count = use_signal(|| start);

    rsx! {
        div { class: "counter",
            span { "{label}: {count}" }
            button { onclick: move |_| count += 1, "+1" }
            Badge { text: "beta" }
        }
    }
}

/// A child whose props never change.
#[component]
pub fn Badge(text: &'static str) -> Element {
    BADGE_RUNS.fetch_add(1, Ordering::SeqCst);
    rsx! { em { "{text}" } }
}

The static atomic counters exist only so the tests can count how often each function body executes. Each component bumps its counter as its first line, which gives hard evidence about what reruns. That fetch_add is itself a side effect in a component body, exactly what the Dioxus rendering guide asks you to keep out of real components. Here it is the measuring tape, and concept C comes back to why it matters. Counter takes two props, label and start, and owns one piece of state, count, created by use_signal(|| start) (concept B covers that line and the click closure). Inside rsx!, the div has a class attribute, the span interpolates {label} and {count} the way format! would, and the last child is Badge { text: "beta" }, a component used like an element. Its name starts with a capital letter, as #[component] requires, and its argument type &'static str implements both PartialEq and Clone.

Rendering the first version once and printing it as HTML takes a few lines:

#[test]
fn ssr_first_render() {
    let mut dom = VirtualDom::new_with_props(
        Counter,
        CounterProps { label: "acked".to_string(), start: 7 },
    );
    dom.rebuild_in_place();
    println!("{}", dioxus_ssr::render(&dom));
}

VirtualDom::new_with_props takes the root component plus a props value, here the generated CounterProps. rebuild_in_place runs the component for the first time and builds the in-memory tree without sending its edits anywhere, a call the docs describe as useful for testing. dioxus_ssr::render walks that tree and returns a string. These are the same three steps the dioxus-ssr crate documentation uses in its own example, with VirtualDom::new in place of new_with_props because its root takes no props. The output is:

<div class="counter"><span>acked: 7</span><button>+1</button><em>beta</em></div>

The label and starting value arrived as props, and the child component became an em. The HTML has no trace of the onclick closure, because listeners travel separately, in the mutation list shown in concept C.

A later test, which clicks the button once, printed Counter ran 2 times, Badge ran 1 times. Counter reran because the signal it read changed. Badge did not, because its props ("beta") compared equal to the previous ones, so the runtime skipped it: props-based memoization, measured.

Common mistake: props without the derives.

B. Signals, hooks and event handlers

Definition. A signal is a tracked value that Dioxus stores outside your function. use_signal(|| start) creates the signal the first time the component runs and returns the same one on every later run, so the value survives reruns even though the function's local variables do not. Copy is the Rust trait for types that are duplicated instead of moved, and signals implement it: they are built on the generational-box crate, whose GenerationalBox copies a handle to a value rather than the value itself. That is why a move closure can capture a signal without calling .clone().

A hook is a function, by convention named with a use_ prefix, that stores state for the current component and finds it again on the next run by call order. All Dioxus hooks are built on a primitive called use_hook: the first time a given call happens, the initializer runs and a new slot is created, and on later runs it returns a clone of the value already in the slot. A "hook index" goes up by one on every call and resets to 0 before the component reruns, so the third use_ call always meets the third slot.

An event handler is a closure attached to an element attribute such as onclick. When the event fires, Dioxus runs the closure, and inside it you can write to signals.

Why it matters, and the subscription rule. A reactive scope is a region of code whose signal reads are recorded so that it can be rerun later, and the simplest reactive scope is a component. During a render, the component subscribes to every signal it reads. That includes the formatting in "{label}: {count}", because Display calls .read() behind the scenes. Writing with count += 1, .set(...) or .write() queues a rerun of every component that subscribed. Writes are batched, so several writes before the next step of the app cause one rerun, not several. If a component never reads a signal while rendering, it never subscribes, and writes to that signal leave it alone. peek() is the deliberate version: it returns the current value without subscribing. In the lab, a component that displayed *count.peek() produced mutations after click: 0 and kept showing <button>count: 0</button>, so keep peek for effects or memos that must not subscribe.

The hook rules follow from slots being matched by order. Call hooks in the same order on every run, only at the top level of a component or another hook, and never inside conditionals, loops or closures; otherwise one run's third call can land on a slot that belonged to a different hook.

Worked example. A headless test needs two small helpers, one to find the button and one to click it:

fn listener_id(edits: &Mutations, name: &str) -> ElementId {
    edits
        .edits
        .iter()
        .find_map(|m| match m {
            Mutation::NewEventListener { name: n, id } if n == name => Some(*id),
            _ => None,
        })
        .expect("no listener")
}

fn click(dom: &VirtualDom, id: ElementId) {
    let data = PlatformEventData::new(Box::new(SerializedMouseData::default()));
    let event = Event::new(Rc::new(data) as Rc<dyn Any>, true);
    dom.runtime().handle_event("click", event, id);
}

rebuild_to_vec runs the first render and returns the list of edits (called mutations, explained in concept C) instead of applying them anywhere. listener_id scans that list for the NewEventListener mutation and returns its ElementId, the numeric handle Dioxus gives each real node so that later edits and events can name it. The event name to look for is "click", without the on prefix used in the onclick attribute. The VirtualDom doc example passes "onclick", but the listener that 0.7.10 registered in this run is named click. click builds a mouse event from SerializedMouseData::default() in dioxus-html, wraps it as platform event data, and calls handle_event on the runtime, which the VirtualDom documentation names as the way to call listeners from outside.

The test uses them to click once and record what changes:

#[test]
fn click_rerenders_and_diffs() {
    dioxus_html::set_event_converter(Box::new(SerializedHtmlEventConverter));
    COUNTER_RUNS.store(0, Ordering::SeqCst);
    BADGE_RUNS.store(0, Ordering::SeqCst);

    let mut dom = VirtualDom::new_with_props(
        Counter,
        CounterProps { label: "acked".to_string(), start: 7 },
    );
    let first = dom.rebuild_to_vec();
    println!("first render: {} mutations", first.edits.len());
    for m in &first.edits {
        println!("  {m:?}");
    }
    let button = listener_id(&first, "click");

    click(&dom, button);
    let second = dom.render_immediate_to_vec();
    println!("after one click: {} mutation(s)", second.edits.len());
    for m in &second.edits {
        println!("  {m:?}");
    }
    println!("{}", dioxus_ssr::render(&dom));
    println!(
        "Counter ran {} times, Badge ran {} times",
        COUNTER_RUNS.load(Ordering::SeqCst),
        BADGE_RUNS.load(Ordering::SeqCst)
    );
}

The set_event_converter call on the first line tells dioxus-html how to turn that platform data into the typed mouse event your closure receives. In a browser the web renderer supplies the converter. In a headless test the serialized converter stands in for it. After the click, render_immediate_to_vec processes the work the click queued and returns the new mutations: exactly one, a SetText carrying "acked: 8" (concept C reads the full list). The HTML now shows <span>acked: 8</span>, and the counters read Counter ran 2 times, Badge ran 1 times. Run the suite with cargo test -- --nocapture --test-threads=1. The first flag shows the println! output, and the second keeps tests from running at the same time, which matters because the run counters are shared statics.

Common mistake 1: a plain local variable instead of a signal.

#[component]
pub fn ForgetfulCounter() -> Element {
    FORGETFUL_RUNS.fetch_add(1, Ordering::SeqCst);
    let mut clicks = 0;
    rsx! {
        button { onclick: move |_| clicks += 1, "clicks: {clicks}" }
    }
}

**Common mistake 2: a hook inside an if.** In HookInIf, the line let note = use_signal(|| "shown"); sits inside if show() { ... }, ahead of another use_signal for total.

C. The rendering model and renderers, with the Leptos contrast

Definition. A virtual DOM is an in-memory tree that describes the UI. The elements you write in rsx! are not handles to real nodes in a browser or window; a platform-specific renderer turns them into real elements and draw calls. After a component reruns, Dioxus compares the old and new Element to find what changed, a process called diffing. It only compares the dynamic parts of the rsx! (the interpolated text and attribute values and child components), because the static parts are compiled into templates. A template describes a UI tree known at compile time, with slots for the dynamic parts filled in at runtime, so unchanged static structure is skipped.

The result of a diff is a list of mutations. A Mutation is a serialized enum value representing one operation to apply to the UI, such as CreateTextNode, SetText or NewEventListener, and a renderer is the part that applies them to a real target. The guide calls the whole loop of render, display, listen and mutate reconciliation, and notes that Dioxus borrows many ideas from React.

Why it matters. Templates and diffing let the framework report that the only change is one piece of text, because the static structure is never re-examined. The mutation list is the boundary between the platform-independent core and whatever draws pixels, so the core does not need to know how to draw on any platform. The custom renderer guide adds that these edits are serializable, which means a UI can even be driven across a network connection. Dioxus offers these renderers:

You choose a platform with Cargo features: the web platform enables dioxus/web, and the desktop platform enables dioxus/desktop.

Worked example. The click test printed the first render as ten mutations:

first render: 10 mutations
  LoadTemplate { index: 0, id: ElementId(1) }
  AssignId { path: [1], id: ElementId(2) }
  NewEventListener { name: "click", id: ElementId(2) }
  LoadTemplate { index: 0, id: ElementId(3) }
  CreateTextNode { value: "beta", id: ElementId(4) }
  ReplacePlaceholder { path: [0], m: 1 }
  ReplacePlaceholder { path: [2], m: 1 }
  CreateTextNode { value: "acked: 7", id: ElementId(5) }
  ReplacePlaceholder { path: [0, 0], m: 1 }
  AppendChildren { id: ElementId(0), m: 1 }
after one click: 1 mutation(s)
  SetText { value: "acked: 8", id: ElementId(5) }

The diff works as a stack machine. LoadTemplate, CreatePlaceholder and CreateTextNode push a new real node onto a stack, while AppendChildren, InsertAfter, InsertBefore, ReplacePlaceholder and ReplaceWith remove nodes from it, using them to fill a spot in the tree. There are two LoadTemplate lines because there are two templates: ElementId(1) is Counter's div with its static structure, and ElementId(3) is Badge's em. A path is a list of child positions counted from a template's root, so AssignId with path [1] names the div's second child, the button, and gives it ElementId(2) so that the next line can attach the click listener to it. The CreateTextNode lines make the dynamic text, "beta" and "acked: 7", as nodes with ids 4 and 5. Each ReplacePlaceholder takes m: 1 node off the stack and drops it into a dynamic slot: path [0] is the text inside Badge's em, path [2] is the div's third child where Badge goes, and path [0, 0] is the text inside the span. The last line, AppendChildren on ElementId(0), the root, attaches the finished tree. A renderer must handle this stream of edits and pass events back into the virtual DOM, so the test, which did both by hand, is a minimal renderer.

After the click, the whole update is one SetText on ElementId(5), the text node that showed "acked: 7". Counter ran again, but the diff found only that text changed; Badge did not run, and the static parts of the templates were never examined.

The contrast with Leptos. Leptos also has components, an HTML-like macro and signals, but a different execution model. Its book says the body of a component function is "a set-up function that runs once, not a render function that reruns multiple times". Its README says a signal change can update a single text node, toggle a class, or remove an element "without any other code running" ("So, no virtual DOM overhead!"), and that Dioxus "still uses coarse-grained/component-scoped reactivity: changing a stateful value reruns the component function and diffs the old UI against the new one", whereas Leptos components create actual DOM nodes and set up a reactive system to update them. That is the Leptos authors' description of both designs. The lab measured only the Dioxus side (one click, Counter ran 2 times, one SetText reached the screen) and makes no speed claim for either framework.

QuestionDioxus 0.7Leptos (per its documentation)
What runs again after a signal changesThe whole component function that read itThe closures and effects that read the signal
What a plain let in the body doesRecomputed on every runComputed once
What reaches the screenMutations from a diff of old and new ElementDirect updates to the DOM nodes

Common mistake: treating the body as setup code.

Putting it together

Follow one click from launch to screen. The first rebuild runs Counter (creating count from start) and Badge once each and produces the ten mutations above. The test, acting as renderer, finds the click listener on ElementId(2). handle_event("click", ...) runs the onclick closure, and count += 1 marks Counter dirty because the first render read count through "{label}: {count}". render_immediate_to_vec reruns Counter but not Badge, diffs, and emits one SetText on ElementId(5). Then dioxus_ssr::render shows <span>acked: 8</span> inside the same div.

Back to the status widget. In Dioxus the code that reruns is the whole function of every component that read a changed signal, minus any child whose props compare equal. State belongs in signals created by use_signal at the top of the function, because a plain local variable is recreated on each run and copied into each move closure. And the widget is testable without a browser, because the framework's output is a list of mutations and an HTML string, both of which a test can read directly.

The dioxus-html dev-dependency with the serialize feature is what makes the click possible. In a browser the web renderer builds the typed mouse event from a real DOM event. A test has none, so SerializedMouseData supplies a plain-data event and SerializedHtmlEventConverter reads it. Only the tests fabricate events, so it stays a dev-dependency that users of the library never compile.

Check yourself

  1. A parent component reruns but a child's props are unchanged. Does the child function run? No. Equal props, by PartialEq, let the runtime skip it, which is why Badge ran once while Counter ran twice.
  2. **Why does let mut clicks = 0; with a move closure never update the screen, even after a forced rerun?** The closure increments its own copy of the Copy integer, nothing marks the component dirty, and any rerun executes let mut clicks = 0; again. A signal from use_signal lives outside the function, and writing it queues a rerun.
  3. **What is the difference between count() and count.peek() inside render?** The first subscribes the component to the signal and the second does not, so a later write reruns the component only in the first case.
  4. **A hook inside an if panics on the second render, yet the test passes. Why, and what should the test check?** Dioxus catches render panics on native targets and logs them instead of crashing. Assert on the mutations or the HTML, which stayed [] and <button>show (0)</button>.
  5. According to the Leptos authors, what does a Leptos component body do that a Dioxus body does not? It runs once as setup and wires signals to DOM nodes, while a Dioxus body reruns and its output is diffed.

Sources