Web — Rust and Yew

These are the steps to set up and run a simple Rust Web app that calls into a shared core.

Note

This walk-through assumes you have already added the shared and shared_types libraries to your repo, as described in Shared core and types.

Info

There are many frameworks available for writing Web applications in Rust. We've chosen Yew for this walk-through because it is arguably the most mature. However, a similar setup would work for any framework that compiles to WebAssembly.

Create a Yew App

Our Yew app is just a new Rust project, which we can create with Cargo. For this example we'll call it web-yew.

cargo new web-yew

We'll also want to add this new project to our Cargo workspace, by editing the root Cargo.toml file.

[workspace]
members = ["shared", "web-yew"]

Now we can start fleshing out our project. Let's add some dependencies to web-yew/Cargo.toml.

[package]
name = "web-yew"
version = "0.1.0"
edition = "2021"

[dependencies]
shared = { path = "../shared" }
yew = { version = "0.21.0", features = ["csr"] }

We'll also need a file called index.html, to serve our app.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Yew Counter</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
  </head>
</html>

Create some UI

Example

There are several, more advanced, examples of Yew apps in the Crux repository.

However, we will use the simple counter example, which has shared and shared_types libraries that will work with the following example code.

Simple counter example

A simple app that increments, decrements and resets a counter.

Wrap the core to support capabilities

First, let's add some boilerplate code to wrap our core and handle the capabilities that we are using. For this example, we only need to support the Render capability, which triggers a render of the UI.

Note

This code that wraps the core only needs to be written once — it only grows when we need to support additional capabilities.

Edit src/core.rs to look like the following. This code sends our (UI-generated) events to the core, and handles any effects that the core asks for. In this simple example, we aren't calling any HTTP APIs or handling any side effects other than rendering the UI, so we just handle this render effect by sending it directly back to the Yew component. Note that we wrap the effect in a Message enum because Yew components have a single associated type for messages and we need that to include both the events that the UI raises (to send to the core) and the effects that the core uses to request side effects from the shell.

Also note that because both our core and our shell are written in Rust (and run in the same memory space), we do not need to serialize and deserialize the data that we pass between them. We can just pass the data directly.

use shared::{Capabilities, Counter, Effect, Event};
use std::rc::Rc;
use yew::Callback;

pub type Core = Rc<shared::Core<Effect, Counter>>;

pub enum Message {
    Event(Event),
    Effect(Effect),
}

pub fn new() -> Core {
    Rc::new(shared::Core::new::<Capabilities>())
}

pub fn update(core: &Core, event: Event, callback: &Callback<Message>) {
    for effect in core.process_event(event) {
        process_effect(core, effect, callback);
    }
}

pub fn process_effect(_core: &Core, effect: Effect, callback: &Callback<Message>) {
    match effect {
        render @ Effect::Render(_) => callback.emit(Message::Effect(render)),
    }
}

Tip

That match statement, above, is where you would handle any other effects that your core might ask for. For example, if your core needs to make an HTTP request, you would handle that here. To see an example of this, take a look at the counter example in the Crux repository.

Edit src/main.rs to look like the following. The update function is interesting here. We set up a Callback to receive messages from the core and feed them back into Yew's event loop. Then we test to see if the incoming message is an Event (raised by UI interaction) and if so we use it to update the core, returning false to indicate that the re-render will happen later. In this app, we can assume that any other message is a render Effect and so we return true indicating to Yew that we do want to re-render.

mod core;

use crate::core::{Core, Message};
use shared::Event;
use yew::prelude::*;

#[derive(Default)]
struct RootComponent {
    core: Core,
}

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

    fn create(_ctx: &Context<Self>) -> Self {
        Self { core: core::new() }
    }

    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
        let link = ctx.link().clone();
        let callback = Callback::from(move |msg| {
            link.send_message(msg);
        });
        if let Message::Event(event) = msg {
            core::update(&self.core, event, &callback);
            false
        } else {
            true
        }
    }

    fn view(&self, ctx: &Context<Self>) -> Html {
        let link = ctx.link();
        let view = self.core.view();

        html! {
            <section class="box container has-text-centered m-5">
                <p class="is-size-5">{&view.count}</p>
                <div class="buttons section is-centered">
                    <button class="button is-primary is-danger"
                        onclick={link.callback(|_| Message::Event(Event::Reset))}>
                        {"Reset"}
                    </button>
                    <button class="button is-primary is-success"
                        onclick={link.callback(|_| Message::Event(Event::Increment))}>
                        {"Increment"}
                    </button>
                    <button class="button is-primary is-warning"
                        onclick={link.callback(|_| Message::Event(Event::Decrement))}>
                        {"Decrement"}
                    </button>
                </div>
            </section>
        }
    }
}

fn main() {
    yew::Renderer::<RootComponent>::new().render();
}

Build and serve our app

The easiest way to compile the app to WebAssembly and serve it in our web page is to use trunk, which we can install with Homebrew (brew install trunk) or Cargo (cargo install trunk).

We can build our app, serve it and open it in our browser, in one simple step.

trunk serve --open

Success

Your app should look like this:

simple counter app