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-unknowntarget. - [ ]
trunkfor client-side builds, orcargo-leptosfor 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.
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.
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.
Gotchas
- Reading a signal without a closure.
{count.get()}renders once;{move || count.get()}is reactive. The compiler accepts both. valueinstead ofprop: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
Copyand 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.
Related
- Comparing Yew, Leptos and Dioxus — choosing between them.
- Calling Web APIs from Rust with wasm-bindgen — the layer underneath the framework.
- Shrinking Rust Wasm with cargo profiles — the size settings in depth.
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