How to Build a Frontend Application with Dioxus

Web
Build a Dioxus frontend by initializing a Cargo project, adding the dioxus crate, defining a component in main.rs, and running cargo run.

From Cargo to Browser in One Step

You are done with the JavaScript dependency spiral. You spent the last weekend debugging a build error caused by a transitive dependency that updated its own dependency. You want to write a UI that does not crash because a string became undefined at runtime. You want to use the same language for your backend API and your frontend dashboard, sharing types and logic without a bridge layer. You hear Dioxus can do this. You want to verify it works without spending three days configuring a bundler or reading a manifesto.

Dioxus lets you build reactive UIs in Rust. It compiles to WebAssembly for the browser, and the same code can target desktop and mobile with minimal changes. You write components as functions, define markup with a macro, and manage state with reactive signals. The compiler catches prop mismatches and type errors before the code runs. You get the component model you know from React or Svelte, backed by Rust's type system.

The Dioxus mental model

Dioxus uses a virtual DOM architecture. You describe the UI as a tree of nodes. When state changes, Dioxus re-renders the affected components, generates a new tree, diffs it against the previous tree, and applies only the necessary updates to the real browser DOM. This reconciliation pattern keeps the UI responsive without touching the browser API for every pixel change.

The core syntax is the rsx! macro. In JavaScript frameworks, you write HTML-like syntax that a transpiler converts to function calls. In Dioxus, rsx! is a compile-time macro that expands into Rust code building a tree of structs. The compiler sees the full tree structure. It checks that your tags exist, your props have the right types, and your children are valid. If you pass a string where a number is expected, you get a compiler error. You do not get a runtime crash.

Think of rsx! as a template engine that runs during compilation. The output is a strongly typed data structure. The Dioxus runtime takes this structure and drives the platform-specific rendering. On the web, that means WebAssembly and the browser DOM. On desktop, it might mean a native window. The component code stays the same.

Minimal setup

Start with a standard Cargo project. Add Dioxus with the web feature enabled. This feature pulls in the WebAssembly bindings and the browser-specific runtime.

cargo new dioxus_app && cd dioxus_app
cargo add dioxus --features web

Replace the contents of src/main.rs with a basic entry point. The launch function initializes the runtime and mounts your root component.

// src/main.rs
use dioxus::prelude::*;

fn main() {
    // launch() sets up the Dioxus runtime for the current platform.
    // On the web, this initializes WASM and mounts the component to the DOM.
    launch(app);
}

// Components are functions that return an Element.
// Element is a type alias for Result<VNode, Error>.
// The compiler infers the return type from the rsx! macro.
fn app() -> Element {
    // rsx! builds a virtual node tree.
    // It looks like HTML, but it is Rust code that constructs structs.
    // The compiler checks all tags and props at compile time.
    rsx! {
        div {
            h1 { "Hello, Dioxus!" }
            p { "This is running in Rust." }
        }
    }
}

Run the application with cargo run. If you have the Dioxus CLI installed, you can use dioxus serve for hot reloading. The compiler produces a WebAssembly binary, the runtime loads it, and the browser displays your component.

Convention aside: use dioxus::prelude::* in every file. The prelude is curated to include the most common types, macros, and traits. It avoids naming conflicts and keeps imports clean. Do not fight the glob import here; the community standard is to embrace it.

Start with cargo new. The rest follows.

How rendering works

When launch(app) runs, Dioxus calls your app function. The rsx! macro expands into code that builds a VNode tree. This tree describes the desired state of the UI. Dioxus compares this tree to the current DOM. On the first render, the DOM is empty, so Dioxus creates all the nodes.

When state changes, Dioxus re-runs the component function. It gets a new tree. It diffs the new tree against the old tree. The diffing algorithm identifies which nodes changed, which were added, and which were removed. It applies only those changes to the DOM. This minimizes expensive browser operations.

The Element return type is a Result. This allows components to fail gracefully. If a component returns an error, Dioxus can display an error boundary instead of crashing the whole app. In practice, most components return Ok(VNode), and the compiler handles the wrapping.

The rsx! macro also supports Rust expressions. You can interpolate variables, call functions, and use control flow. The macro captures these expressions and embeds them in the virtual node tree. This keeps the UI logic close to the markup while maintaining type safety.

Trust the borrow checker in UI code. It prevents dangling references and data races even in reactive updates.

Reactive state with Signals

Static UIs are boring. Real apps need state. Dioxus provides Signal<T> as the primary reactive primitive. A signal holds a value and notifies the UI when the value changes. When a signal updates, Dioxus automatically re-renders the components that read that signal.

use dioxus::prelude::*;

fn main() {
    launch(app);
}

fn app() -> Element {
    // Signal<T> is the reactive state container.
    // use_signal creates a signal scoped to the component.
    // The closure provides the initial value.
    let count = use_signal(|| 0);

    rsx! {
        div {
            // Interpolating a signal reads its current value.
            // Dioxus tracks this read and schedules a re-render when count changes.
            h1 { "Counter: {count}" }

            // Event handlers are closures.
            // The move keyword captures the signal by value.
            // Signals are Copy, so this is cheap.
            button {
                onclick: move |_| count += 1,
                "Increment"
            }
            button {
                onclick: move |_| count -= 1,
                "Decrement"
            }
        }
    }
}

Signals implement standard operators. You can use +=, -=, push, insert, and more. The signal handles the notification automatically. You do not need to call a "set state" function manually. The signal is the state.

Convention aside: use move closures for event handlers. Handlers often outlive the scope where they are defined. The move keyword forces the closure to take ownership of captured variables. Since signals are Copy, this works seamlessly. If you capture a reference, you risk lifetime errors.

Signals are the heartbeat of your app. If the signal changes, the UI updates.

Lists and keys

Real apps render lists. Dioxus supports iteration inside rsx!. You can use for loops to generate children. The diffing algorithm needs help to match items efficiently. You provide a key prop to identify each item.

use dioxus::prelude::*;

fn main() {
    launch(app);
}

fn app() -> Element {
    // A signal holding a vector of items.
    let items = use_signal(|| vec!["Rust", "Dioxus", "WASM"]);

    rsx! {
        ul {
            // Iterate over the items.
            // The key prop helps Dioxus track items across re-renders.
            // Without a key, Dioxus might reuse nodes incorrectly when the list changes.
            for item in items.read().iter() {
                li {
                    key: "{item}",
                    "{item}"
                }
            }
        }
    }
}

The key must be unique among siblings. Use an ID from your data model, not the index. If you use the index, inserting or deleting items causes Dioxus to update the wrong nodes. This leads to visual glitches and lost state.

Convention aside: always provide a key for list items. It is a small detail that prevents subtle bugs. The community calls this the "stable key" rule. If your data has no natural ID, generate one. Do not skip keys.

Keys keep the diffing algorithm honest. Skip them and you pay in bugs.

Common pitfalls

You will hit compiler errors. They are helpful. They point to the exact problem.

If you try to render a type that Dioxus does not know how to display, you get E0277 (trait bound not satisfied). Dioxus requires types to implement ToString or Render. Primitive types like i32, String, and bool implement these traits automatically. Custom structs do not. You must implement ToString or convert the struct to a string before rendering.

// This fails with E0277.
// rsx! { div { my_struct } }

// Fix: implement ToString or convert.
rsx! { div { "{my_struct}" } }

If you try to mutate a variable inside a component, you get E0596 (cannot borrow as mutable). Component functions are called multiple times. They must be pure with respect to their inputs. State must live in a signal or context. Do not use let mut for UI state. Use use_signal.

If you capture a reference in a closure that outlives the scope, you get lifetime errors like E0502 or E0597. Event handlers are stored and called later. They need owned data. Use move closures and signals. Signals are Copy, so they move cheaply.

If you forget the key prop in a list, the compiler does not complain. The UI might glitch when items are added or removed. This is a runtime issue. Always add keys.

Do not fight the compiler here. Use signals for state, move for closures, and keys for lists.

When to use Dioxus

Dioxus fits specific needs. It is not the only tool. Choose based on your constraints.

Use Dioxus when you want a single codebase that compiles to web, desktop, and mobile. Use Dioxus when you need type safety in your UI logic and want to catch prop errors at compile time. Use Dioxus when you are building a dashboard or tool where performance and memory usage matter. Use Dioxus when your team already knows Rust and you want to share types between backend and frontend.

Reach for React when you need the massive ecosystem of JavaScript libraries and do not want to learn a new build toolchain. Reach for HTMX when your app is mostly content and you do not need complex client-side state. Reach for Tauri or Electron when you need a desktop app with a web frontend and want to leverage existing web technologies.

The decision matrix is clear. Pick the tool that matches your stack and your state complexity.

Where to go next