Web — Rust and Dioxus

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 Dioxus for this walk-through. However, a similar setup would work for other frameworks that compile to WebAssembly.

Create a Dioxus App

Tip

Dioxus has a CLI tool called dx, which can initialize, build and serve our app.

cargo install dioxus-cli

Test that the executable is available.

dx --help

Before we create a new app, let's add it to our Cargo workspace (so that the dx tool won't complain), by editing the root Cargo.toml file.

For this example, we'll call the app web-dioxus.

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

Now we can create a new Dioxus app. The tool asks for a project name, which we'll provide as web-dioxus.

dx create

cd web-dioxus

Now we can start fleshing out our project. Let's add some dependencies to the project's Cargo.toml.

[package]
name = "web-dioxus"
version = "0.1.0"
authors = ["Stuart Harris <stuart.harris@red-badger.com>"]
edition = "2021"

[dependencies]
console_error_panic_hook = "0.1.7"
dioxus = { version = "0.5", features = ["web"] }
dioxus-logger = "0.5.1"
futures-util = "0.3.30"
shared = { path = "../shared" }
tracing = "0.1.40"

Create some UI

Example

There is slightly more advanced example of a Dioxus app 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 updating the component's view hook with the core's ViewModel.

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 std::rc::Rc;

use dioxus::{
    prelude::{Signal, UnboundedReceiver},
    signals::Writable,
};
use futures_util::StreamExt;
use shared::{Counter, Effect, Event, ViewModel};
use tracing::debug;

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

pub struct CoreService {
    core: Core,
    view: Signal<ViewModel>,
}

impl CoreService {
    pub fn new(view: Signal<ViewModel>) -> Self {
        debug!("initializing core service");
        Self {
            core: Rc::new(shared::Core::new()),
            view,
        }
    }

    pub async fn run(&self, rx: &mut UnboundedReceiver<Event>) {
        let mut view = self.view;
        *view.write() = self.core.view();
        while let Some(event) = rx.next().await {
            self.update(event, &mut view);
        }
    }

    fn update(&self, event: Event, view: &mut Signal<ViewModel>) {
        debug!("event: {:?}", event);

        for effect in self.core.process_event(event) {
            process_effect(&self.core, effect, view);
        }
    }
}

fn process_effect(core: &Core, effect: Effect, view: &mut Signal<ViewModel>) {
    debug!("effect: {:?}", effect);

    match effect {
        Effect::Render(_) => {
            *view.write() = core.view();
        }
    };
}

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. This code sets up the Dioxus app, and connects the core to the UI. Not only do we create a hook for the view state but we also create a coroutine that plugs in the Dioxus "service" we defined above to constantly send any events from the UI to the core.

mod core;

use dioxus::prelude::*;
use tracing::Level;

use shared::{Event, ViewModel};

use core::CoreService;

#[component]
fn App() -> Element {
    let view = use_signal(ViewModel::default);

    let core = use_coroutine(|mut rx| {
        let svc = CoreService::new(view);
        async move { svc.run(&mut rx).await }
    });

    rsx! {
        main {
            section { class: "section has-text-centered",
                p { class: "is-size-5", "{view().count}" }
                div { class: "buttons section is-centered",
                    button { class:"button is-primary is-danger",
                        onclick: move |_| {
                            core.send(Event::Reset);
                        },
                        "Reset"
                    }
                    button { class:"button is-primary is-success",
                        onclick: move |_| {
                            core.send(Event::Increment);
                        },
                        "Increment"
                    }
                    button { class:"button is-primary is-warning",
                        onclick: move |_| {
                            core.send(Event::Decrement);
                        },
                        "Decrement"
                    }
                }
            }
        }
    }
}

fn main() {
    dioxus_logger::init(Level::DEBUG).expect("failed to init logger");
    console_error_panic_hook::set_once();

    launch(App);
}

We can add a title and a stylesheet by editing examples/simple_counter/web-dioxus/Dioxus.toml.

[application]
name = "web-dioxus"
default_platform = "web"
out_dir = "dist"
asset_dir = "public"

[web.app]
title = "Crux Simple Counter example"

[web.watcher]
reload_html = true
watch_path = ["src", "public"]

[web.resource]
style = ["https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css"]
script = []

[web.resource.dev]
script = []

Build and serve our app

Now we can build our app and serve it in one simple step.

dx serve

Success

Your app should look like this:

simple counter app