How to Build a Frontend Application with Yew (Rust WASM Framework)

Web
Build a Yew frontend by initializing a Cargo project, adding the yew dependency, and running trunk serve to compile and host the app.

When the backend needs a face

You've spent weeks wrestling with lifetimes on the backend. The API is fast, the types are solid, and the compiler is your best friend. Now you need a frontend. The JavaScript ecosystem offers a thousand frameworks, but the bundle sizes are bloated, the type system is optional, and the runtime surprises are endless. You want to write UI code that feels like Rust. You want the compiler to catch a missing prop before the user clicks a button. You want WebAssembly to run your logic at near-native speed.

That's where Yew comes in. Yew brings React-style component architecture to Rust, compiling everything down to WebAssembly so it runs in any modern browser without sacrificing the safety guarantees you rely on. You get hot reloading, virtual DOM diffing, and a component model that feels familiar if you've touched React, but with the compile-time guarantees of Rust.

Yew and the component tree

Yew isn't a replacement for the browser. It's a library that lets you describe your UI as a tree of components. Each component encapsulates a piece of state and a view. Yew tracks changes to that state and updates the DOM efficiently using a virtual DOM. The virtual DOM is a lightweight in-memory representation of the actual DOM. Yew compares the new virtual DOM against the old one, calculates the minimal set of changes, and applies them to the real DOM. This approach keeps rendering fast and predictable.

Under the hood, Yew compiles to WebAssembly. WebAssembly (WASM) is a binary instruction format that browsers understand. It's not a new language; it's a target. You write Rust, the compiler translates it to WASM, and the browser executes it alongside JavaScript. WASM gives you near-native performance and memory safety. It also means your frontend code runs in a sandbox, isolated from the rest of the browser.

The ecosystem relies on a tool called trunk. trunk is the build tool for Rust WASM projects. It watches your files, compiles them to WASM, bundles the HTML and CSS, and serves the result. It's the glue that holds the development experience together. Without trunk, you'd have to manually configure webpack or parcel, which is painful for Rust projects.

Convention aside: The community standard for Yew apps is trunk. wasm-pack is excellent for building WASM libraries that other tools consume, but for a full frontend application, trunk provides the HTML templating, CSS processing, and hot reload loop that developers expect. Stick with trunk unless you have a specific reason to do otherwise.

Your first component

Start with a minimal project. Create a new library crate, add the dependencies, and write a component.

cargo new my-yew-app --lib
cd my-yew-app
cargo add yew wasm-bindgen
cargo install trunk

Add a index.html file in the project root. trunk uses this file as the entry point.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8"/>
    <title>My Yew App</title>
  </head>
  <body>
    <!-- Trunk injects the WASM module here. -->
  </body>
</html>

Now write the component. Yew uses a macro called html! to generate the DOM tree. The macro checks types at compile time. If you pass a string where a number is expected, the code won't compile.

use yew::prelude::*;

/// A simple counter component using Yew hooks.
pub struct Counter;

impl Component for Counter {
    type Message = ();
    type Properties = ();

    fn create(_: Self::Properties, _link: ComponentLink<Self>) -> Self {
        Counter
    }

    fn update(&mut self, _msg: Self::Message) -> ShouldRender {
        ShouldRender::yes()
    }

    fn view(&self) -> Html {
        // use_state creates a reactive state variable.
        // The closure initializes the value.
        let count = use_state(|| 0);

        // Callback::from wraps the closure in a type that Yew can store.
        // Cloning count is necessary because the closure captures it.
        let on_click = Callback::from({
            let count = count.clone();
            move |_| {
                count.set(*count + 1);
            }
        });

        html! {
            <div>
                <p>{"Count: "}{*count}</p>
                <button onclick={on_click}>
                    {"Increment"}
                </button>
            </div>
        }
    }
}

fn main() {
    // yew::start_app mounts the component to the document body.
    yew::start_app::<Counter>();
}

Run trunk serve --open. The browser opens, the counter appears, and clicking the button updates the display. Hot reload works automatically. Edit the code, save the file, and the browser refreshes instantly.

Convention aside: Use yew::prelude::* in every file that uses Yew. The prelude imports the most common types and macros, including html!, Component, use_state, and Callback. It saves typing and keeps imports consistent across the project.

How the build works

When you run trunk serve, the tool performs several steps. First, it compiles your Rust code to the wasm32-unknown-unknown target. This target produces a WASM binary instead of a native executable. The binary contains your logic, but it doesn't know how to interact with the browser. That's where wasm-bindgen comes in. wasm-bindgen generates JavaScript glue code that bridges WASM and the browser APIs. It lets you call DOM methods, handle events, and access the console from Rust.

trunk bundles the WASM binary, the glue code, your HTML, and your CSS into a single output. It serves the result over HTTP. The browser loads the HTML, which loads the JavaScript, which instantiates the WASM module. Yew takes over from there, mounting your component tree and managing updates.

The wasm32-unknown-unknown target is not installed by default. If you get an error about a missing target, run rustup target add wasm32-unknown-unknown. This downloads the standard library for WASM. Without it, the compiler can't link your code.

Pitfall: If you try to use std::thread or other OS-specific features, the build fails. WASM runs in a sandbox with limited system access. You can't spawn threads directly. Use async tasks or Web Workers via wasm-bindgen-futures if you need concurrency. The compiler rejects you with a link error if you try to use unsupported features.

Don't fight the build tool. trunk exists to make WASM development feel like a standard web workflow. Use it.

State and events

State management in Yew revolves around use_state. The hook returns a UseStateHandle<T>. Dereferencing the handle gives you the current value. Calling set updates the value and triggers a re-render. Yew batches updates for performance. Multiple state changes in a single event handler result in one re-render.

Events are handled via Callback. The Callback type wraps a closure and ensures it has the right lifetime and type. Yew's event types, like MouseEvent or InputEvent, are generated by wasm-bindgen. They mirror the browser's event objects.

use yew::prelude::*;

/// A todo app using controlled inputs.
pub struct TodoApp;

impl Component for TodoApp {
    type Message = ();
    type Properties = ();

    fn create(_: Self::Properties, _link: ComponentLink<Self>) -> Self {
        TodoApp
    }

    fn update(&mut self, _msg: Self::Message) -> ShouldRender {
        ShouldRender::yes()
    }

    fn view(&self) -> Html {
        let todos = use_state(|| Vec::new());
        let input_value = use_state(|| String::new());

        // Handle input changes.
        let on_change = {
            let input_value = input_value.clone();
            Callback::from(move |e: InputEvent| {
                // target_unchecked_into_value converts the event target to an input element.
                input_value.set(e.target_unchecked_into_value().value());
            })
        };

        // Handle button click.
        let on_click = {
            let todos = todos.clone();
            let input_value = input_value.clone();
            Callback::from(move |_| {
                if !input_value.is_empty() {
                    todos.push(input_value.clone());
                    input_value.set(String::new());
                }
            })
        };

        html! {
            <div>
                <input value={(*input_value).clone()} oninput={on_change} />
                <button onclick={on_click}>{"Add"}</button>
                <ul>
                    {for todos.iter().map(|t| html!{ <li>{t}</li> })}
                </ul>
            </div>
        }
    }
}

The html! macro supports loops and conditionals. The for expression iterates over an iterator and renders each item. The if expression renders a block conditionally. These constructs are type-checked. If the types don't match, the compiler rejects the code.

Ah-ha: Rc<T> implements PartialEq by comparing pointers, not contents. If you store an Rc in props or state and update the inner value without replacing the Rc, Yew won't re-render. The virtual DOM sees the same pointer and assumes nothing changed. Wrap Rc in a newtype that implements PartialEq by value if you need content-based diffing.

Controlled inputs keep your state in sync with the DOM. Reach for use_state for every form field; it prevents the desync bugs that plague manual DOM manipulation.

Pitfalls and compiler errors

Yew code is mostly Rust code, so the compiler errors are familiar. However, the html! macro adds a layer of indirection. Macro errors can point to the wrong line. Read the full error chain. The root cause is usually a type mismatch in your props or a missing trait implementation.

Common errors include:

  • E0277 (the trait bound is not satisfied): You tried to render a type that doesn't implement IntoPropValue. Wrap the value in a string or use a type that Yew knows how to render.
  • E0382 (use of moved value): You forgot to clone a state handle inside a closure. Closures capture their environment by value. Clone the handle before moving it into the Callback.
  • E0599 (no function or associated item named ...): You're missing a trait import. Add use yew::prelude::* or import the specific trait.

Props require #[derive(Properties, PartialEq)]. The PartialEq derive is mandatory. Yew uses it to diff props and decide whether to re-render. If you omit PartialEq, the compiler rejects the code. If you implement PartialEq manually, ensure it compares the fields that affect rendering.

Convention aside: Keep html! blocks indented consistently. The macro is sensitive to whitespace in some contexts. cargo fmt handles most formatting, but complex html! blocks sometimes need manual adjustment. Don't argue style; argue logic. If the code compiles and renders correctly, the formatting is fine.

The compiler errors in Yew are often macro errors that point to the wrong line. Read the full chain. The root cause is usually a type mismatch in your props or a missing trait implementation.

Choosing your framework

Rust's frontend ecosystem is growing. Several frameworks offer different trade-offs. Pick the one that matches your needs.

Use Yew when you want a mature, React-like component model with a large ecosystem and stable APIs. Yew has been around the longest in the Rust WASM space. It has extensive documentation, a large community, and support for server-side rendering. It's the safe choice for production applications.

Use Leptos when you prioritize fine-grained reactivity and want to avoid virtual DOM overhead. Leptos uses signals to track dependencies and update only the parts of the DOM that change. It's faster than Yew for high-frequency updates, but the API is different. Leptos feels closer to SolidJS than React.

Use Sycamore when you prefer a functional component style that feels closer to Vue or SolidJS. Sycamore uses a template macro that's distinct from html!. It's lightweight and fast, but the ecosystem is smaller.

Reach for plain JavaScript when your project is a simple landing page and the WASM build time outweighs the benefits of Rust. If you don't need type safety or performance, JavaScript is simpler.

Yew is the safe harbor for Rust frontend. It's not the fastest framework in the WASM world, but it's the most predictable. Pick Yew when stability and familiarity matter more than micro-optimizations.

Where to go next