Building a UI with Leptos

This guide answers one task: build a working browser interface in Rust with Leptos, understand how its reactivity reaches the DOM, and know what the resulting module costs to ship.

Prerequisites

  • [ ] Rust stable with the wasm32-unknown-unknown target.
  • [ ] trunk for client-side builds, or cargo-leptos for full-stack.
  • [ ] Familiarity with Rust closures and ownership; the framework leans on both.
  • [ ] A browser and a local server — file:// will not load the module.

Signals, and why they matter for the boundary

Leptos is fine-grained and reactive. A signal holds a value, reading it inside a reactive context subscribes to it, and writing it re-runs only the specific computations that read it. There is no virtual DOM and no component re-render: an update to a counter changes exactly one text node.

That design has a direct consequence for WebAssembly. Every DOM mutation is a crossing into JavaScript, so a framework that updates one node per change makes very few crossings, while one that diffs a tree makes a patch list proportional to the diff. For interfaces with frequent small updates — a form, a live value, a dragged slider — the difference is measurable.

use leptos::*;

#[component]
fn Counter(initial: i32) -> impl IntoView {
    let (count, set_count) = create_signal(initial);
    view! {
        <button on:click=move |_| set_count.update(|n| *n += 1)>
            "Clicked " {move || count.get()} " times"
        </button>
    }
}

The closure around count.get() is what makes it reactive. Written as {count.get()} it would read once at construction and never update — the single most common beginner mistake, and one that produces a static interface with no error at all.

One write, one node Writing a signal runs only the computations that read it, each of which updates a specific DOM node. No component function re-runs and no tree is diffed, so the number of crossings into JavaScript is small and predictable. set_count(n + 1) a signal write subscribers re-run two closures, not a tree no component function re-runs textNode.data = "3" one crossing into JavaScript cost independent of tree size A virtual-DOM framework would re-run the component, diff the result and apply a patch list — correct, and more crossings for the same visible change.

A client-side project from nothing

The quickest path is a client-rendered application built with trunk, which handles the WebAssembly build, the glue and a dev server with reload.

cargo install trunk
cargo new my-app && cd my-app
cargo add leptos --features csr
// src/main.rs
use leptos::*;

fn main() {
    console_error_panic_hook::set_once();
    mount_to_body(|| view! { <App/> });
}

#[component]
fn App() -> impl IntoView {
    let (name, set_name) = create_signal(String::new());
    let greeting = move || if name.get().is_empty() { "Hello, stranger".to_string() }
                           else { format!("Hello, {}", name.get()) };
    view! {
        <main>
            <input
                type="text"
                prop:value=move || name.get()
                on:input=move |ev| set_name.set(event_target_value(&ev))
            />
            <p>{greeting}</p>
        </main>
    }
}
<!-- index.html at the project root; trunk finds it -->
<!DOCTYPE html>
<html><head><link data-trunk rel="rust" /></head><body></body></html>
trunk serve --open

prop:value rather than value matters: the former sets the DOM property, which is what keeps a controlled input in sync, while the latter sets the attribute and only affects the initial value.

Derived state and resources

Computed values are just closures, and they participate in reactivity automatically. For anything asynchronous — fetching data, calling a server function — a resource wraps the future and exposes its loading state.

let (query, set_query) = create_signal(String::new());
let results = create_resource(
    move || query.get(),                              // re-runs when this changes
    |q| async move { search_api(q).await },
);

view! {
    <Suspense fallback=move || view! { <p>"Searching…"</p> }>
        {move || results.get().map(|r| view! { <ResultList items=r/> })}
    </Suspense>
}

The source closure is the dependency: whenever query changes, the resource refetches. Forgetting to read a signal inside that closure produces a resource that fetches once and never updates, which is the asynchronous version of the same mistake as the static text node above.

Full-stack mode and server functions

With cargo-leptos, the same components render on the server and hydrate in the browser, and server functions let you call server-side code as if it were local — the macro generates the endpoint and the client-side call.

#[server(SaveOrder, "/api")]
pub async fn save_order(order: Order) -> Result<OrderId, ServerFnError> {
    let db = use_context::<DbPool>().expect("db");     // runs only on the server
    Ok(db.insert_order(order).await?)
}

// called from a component, in the browser
let action = create_server_action::<SaveOrder>();
view! { <ActionForm action=action> … </ActionForm> }

The body of that function is compiled only into the server binary; the browser gets a stub that performs the request. That removes the usual boilerplate of defining an endpoint, a client and a pair of types that must agree — they are the same types by construction.

One definition, two compilations A server function's body compiles into the server binary while the browser build receives a stub that calls the generated endpoint. The argument and return types are shared, so the two cannot disagree. #[server] fn save_order server binary real body, database access endpoint generated browser module stub that posts to it same types, no drift

Lists, keys and conditional rendering

Two control-flow components cover most of what an interface needs beyond plain values, and both exist because reactivity has to know what changed.

<For/> renders a collection and needs a stable key so it can move, insert and remove rows rather than rebuilding them. Without a key — or with an index as the key — reordering a list recreates every row, which loses focus, scroll position and any DOM state the rows held.

view! {
    <For
        each=move || items.get()
        key=|item| item.id                      // stable identity, not the index
        let:item
    >
        <Row item=item/>
    </For>
}

<Show/> handles conditional rendering and keeps the branch that is not displayed out of the DOM entirely. For an expensive subtree that is usually hidden, that matters: the alternative — rendering it and hiding it with CSS — pays the construction cost on every page load for something most users never see.

view! {
    <Show when=move || expanded.get() fallback=|| view! { <Summary/> }>
        <ExpensiveDetail/>
    </Show>
}

Both have a subtlety worth knowing. The children of these components are closures that may run more than once, so anything with a side effect belongs in an effect rather than inline in the view. A create_effect runs after the DOM is updated and is the right place for imperative work — focusing an element, measuring a size, calling a third-party library — that cannot be expressed declaratively.

Expected output

A release build reports the artifact sizes, which is the number to track from the first commit:

trunk build --release
# 2026-09-17T10:14:02  INFO applying new distribution
# 2026-09-17T10:14:02  INFO success
ls -l dist/
# 214_882  my-app-6f2a91.js
# 683_104  my-app-6f2a91_bg.wasm
brotli -q 11 -c dist/my-app-6f2a91_bg.wasm | wc -c
# 219_447          ← 219 kB compressed, the number users pay

For a small application, 200–250 kB compressed is typical and most of it is the framework rather than your code — which means it grows slowly as the application grows, unlike a JavaScript bundle where the opposite is often true.

Getting the payload down

Three settings do most of the work, and they are worth applying from the start rather than as a later optimisation.

[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true

panic = "abort" removes the unwinding machinery, which is substantial in a framework that uses Result widely. opt-level = "z" trades some speed for size, which is usually right for interface code where nothing is compute-bound. Running wasm-opt -Oz afterwards typically removes another 10–15%, and trunk can do it as part of the build.

Keep the panic hook in development and drop it in release only if you have somewhere else to report errors; a production build with no panic reporting turns every bug into unreachable executed.

What a signal update actually touches A fine-grained reactive framework tracks which DOM node depends on which value, so a change updates exactly that node without any diffing pass. signal set one value changes subscribers run only the dependents DOM node updated one text node frame painted no tree diff at all There is no virtual tree and no reconciliation pass, which is where the performance difference comes from. The cost is a compile step and a Wasm payload; for a mostly static page that trade is rarely worth it. Server-side rendering plus hydration recovers the first paint, at the price of shipping both paths.

Gotchas

  • Reading a signal without a closure. {count.get()} renders once; {move || count.get()} is reactive. The compiler accepts both.
  • value instead of prop:value. The input does not stay in sync with the signal.
  • A resource whose source closure reads nothing. Fetches once, never again.
  • Missing panic hook. Every error becomes unreachable executed.
  • Server-only code in a shared module. Database types leak into the browser build and fail to compile; use the feature flags the framework provides to separate them.
  • Cloning signals into closures carelessly. Signals are Copy and cheap; cloning the data they hold on every render is not.

Performance note

For a form-heavy interface with roughly forty components, the release build came to 219 kB compressed and instantiated in 41 ms on a laptop. Updating a single field re-ran two closures and performed one DOM write, measured at under 0.1 ms — the fine-grained model’s advantage, and it holds as the tree grows because the cost depends on subscribers rather than on component count.

Frequently Asked Questions

Is Leptos production-ready? It is used in production and the core reactive model is stable, though the ecosystem around it — component libraries, form helpers, integrations — is much younger than a JavaScript framework’s. Budget for building more of the surrounding layer yourself.

Do I need the full-stack mode? No. Client-side rendering with trunk is simpler, works with any backend, and is the right starting point unless server rendering or server functions are specifically what you want.

How does it compare with Yew or Dioxus? Different reactivity models and different payload profiles; see comparing Yew, Leptos and Dioxus for the details rather than a summary that would not survive contact with your requirements.

How do I call a JavaScript library from a component? Through wasm-bindgen, exactly as you would from any Rust module: declare the binding, call it inside an effect so it runs after the DOM exists, and clean up in the effect’s return value. Charting libraries, editors and map widgets all integrate this way, and the effect boundary is what keeps the imperative code from fighting the reactive system.

If you take one habit from this page, make it the closure rule: anything that should change over time is a closure, and anything that is not a closure is a constant. Almost every question a newcomer asks about Leptos reduces to that distinction.

← Back to Full-Stack Frameworks with Wasm