DEV Community

loading...

Reactive UI components in Rust

Sean Watters
Software Engineer | JavaScript, Rust, WebAssembly 🦀🕸 | he/him
Updated on ・11 min read

Building reactive user interfaces with Rust and WebAssembly

Component lifecycle — Comprised of a "view" node, pointing to and action node titled "JavaScript" with an arrow titled "user event." The action node points to a "calculate" node with an arrow titled "pass data to rust." The "calculate" node points to a "render" node, with an arrow titled "feed new state to render." The render node points back to the "view" node with an arrow titled "inject new render"

First I’ll start off by saying, just because you can do what we’re going to talk about in this post doesn’t mean you should. This is an idea and a first step to exploring a functional approach to building reactive interfaces with Rust and WebAssembly.

The goal of this post — as with other posts I’ve written about WebAssembly in the past — is to show what we can do with WebAssembly and demonstrate that it doesn’t just have to be a tool for pulling computationally intensive algorithms out of our JavaScript, or porting games to the web.

High level

When we load up our app, we’re kicking off our component’s reactive lifecycle by initializing it with a call to WebAssembly, from our JavaScript. On subsequent state changes — triggered by user or other external events — we’ll pass new information back through the cycle and rebuild our component in Rust.

Our state management approach is similar to that of Redux, Vuex and other Flux architectures, just on a smaller scale. Our user event triggers an action in JavaScript which tells WebAssembly we need to recalculate our state, and re-render the view. A key benefit of doing these state calculations in Rust is that the existing state never leaves our sandboxed environment; we only ever pass a reference to our Rust closure — which “closes” over the current state — to an event listener in our JavaScript.

Taking a more functional approach also means that we can avoid mutability and it doesn’t require us to update the state of long lived objects, which makes our component code much more declarative and less error prone.

Code

If you’re feeling like, “just show me the code!” you can check it out here

Otherwise...

To implement what we’ve discussed above, we’ll build out a form as our Rust UI component, and at each step, map out how it ties into the reactive lifecycle.

We’re going to follow a structure that will likely feel familiar for those coming from SPA backgrounds. We won’t worry too much about styling for now, but similar to SFCs or JSX: the “meat” of our component will group the logic away from our template, while we do our work in a single file.

Setup

Prerequisites: npm installed, rust installed, wasm-pack installed.

Generate, build and run the project:

npm init rust-webpack && npm run build && npm run start
Enter fullscreen mode Exit fullscreen mode

View

First we’ll start off with our HTML template. Given that we don’t have a nifty SFC parser the way other template based frameworks do we’ll have to be somewhat creative; we will still need to think about manually adding event listeners to our template after it’s rendered, but conditional logic and iteration will still feel similar.

Before we create our initial template, we’ll need to complete a couple of steps:

  1. Add "Window", "Document", and "Element" to our features list for the web_sys crate, in our Cargo.toml file.
  2. Update the web_sys version to 0.3.5.
  3. Add mod form; to the import set at the top of our lib.rs file.

Now we can create a form.rs file in our src/ directory, with the following content:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn init_form(name: &str) {
    let window = web_sys::window().expect("global `window` should exist");
    let document = window.document().expect("should have a `document` on `window`");

    let root = document
        .get_element_by_id("root")
        .expect("page `root` exists");
    root.set_inner_html("");

    let form_node: web_sys::Element = document
        .create_element("form")
        .expect("DOM element to have been created");

    let template: &str = &gen_template(name);
    form_node.set_inner_html(template);

    root.append_child(&form_node).expect("`form` to have been appended to `root`");
}

fn gen_template(name: &str) -> String {
    format!(
        "
            <h1>User Form</h1>
            <label for=\"name\">Name</label>
            <input type=\"text\" id=\"name\" name=\"name\" value=\"{}\">
            <input id=\"submit\" type=\"submit\" value=\"Submit\">
        ",
        name,
    )
}
Enter fullscreen mode Exit fullscreen mode

Before we explain what’s going on here, we have to do a couple more steps to get our form template into the browser:

We’ll need to update our index.html file in the static/ directory to include the <div id=root></div> element:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>My Rust + Webpack project!</title>
  </head>
  <body>
    <script src="index.js"></script>
    <div id="root"></div>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Next we’ll create a form.js file in the js/ directory that initializes our Rust form:

import { init_form } from "../pkg/index.js";

init_form("Taylor");
Enter fullscreen mode Exit fullscreen mode

And update our import in the js/index.js file:

import("./form.js").catch(console.error);
Enter fullscreen mode Exit fullscreen mode

Now if we run npm run build && npm run start we should see something that looks like this in our browser:

User form — titled "User Form" with a "name" label and field and a submit button. The string in the name field is "Taylor"

Explanation: So what’s going on here? Well, in the form.rs file on line 4, we’ve created the form initializer init_form() that will accept a name: &str from our form.js file on initial render. On line 22 of form.rs we’ve created our template generator gen_template(). The template generator accepts the same arguments as our init_form() so that it can display the initial values of the form.

To break down the init_form() function: we’re using the web_sys crate to facilitate DOM interaction. WebAssembly doesn’t have direct access to the DOM, so web_sys in partnership with wasm_bindgen are generating JavaScript for us, behind the scenes that abstracts this limitation away from us. We’re first grabbing a reference to the window & document so that we can append our form to the <div id=root></div> element. We access the root element by using get_element_by_id() — a method provided to us by web_sys. The next step is to generate our template using the gen_template() function, and inject it into the root element.

Breaking down gen_template(): our template generator is simply interpolating the name argument from init_form() into a string of HTML using Rust’s !format().

Action

Now that we’ve got our form template built out, we can add our event handlers. Similar to the way we’re managing DOM interaction in our form initializer, we’ll need to add some features to web_sys and bring in JsCast from wasm_bindgen.

  1. Add HtmlFormElement and FormData to the list of web_sys features.
  2. Add the line use wasm_bindgen::JsCast; to the top of our form.rs file.

Finally, we can add our submit handler:

use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;

#[wasm_bindgen]
pub fn init_form(name: &str) {
    let window = web_sys::window().expect("global `window` should exist");
    let document = window.document().expect("should have a `document` on `window`");

    let root = document
        .get_element_by_id("root")
        .expect("page `root` exists");
    root.set_inner_html("");

    let form_node: web_sys::Element = document
        .create_element("form")
        .expect("DOM element to have been created");

    let template: &str = &gen_template(name);
    form_node.set_inner_html(template);

    // new code
    let form_node = add_submit_handler(form_node);

    root.append_child(&form_node).expect("`form` to have been appended to `root` node");
}

// new code
fn add_submit_handler(form_node: web_sys::Element) -> web_sys::Element {
    let closure = Closure::wrap(Box::new(move |event: web_sys::Event| {
        event.prevent_default();

        let target = event.current_target().unwrap();
        let form = target.dyn_ref::<web_sys::HtmlFormElement>().unwrap();
        let data = web_sys::FormData::new_with_form(form).unwrap();

        let name: String = data
            .get("name")
            .as_string()
            .expect("`name` to exist in form data");

        web_sys::console::log_1(&name.into());
    }) as Box<dyn FnMut(_)>);

    let cb = closure.as_ref().unchecked_ref();

    form_node
        .add_event_listener_with_callback("submit", cb)
        .expect("`submit_handler` to have been added");
    closure.forget();
    form_node
}

fn gen_template(name: &str) -> String {
    format!(
        "
            <h1>User Form</h1>
            <label for=\"name\">Name</label>
            <input type=\"text\" id=\"name\" name=\"name\" value=\"{}\">
            <input id=\"submit\" type=\"submit\" value=\"Submit\">
        ",
        name
    )
}
Enter fullscreen mode Exit fullscreen mode

Explanation: All code new to this file has // new code commented above it (line 22 and lines 28–51 are new).

Breaking down add_submit_handler(): the first thing we can notice is that this function accepts a web_sys::Element argument; lucky for us, our form_node declared in the init_form() function (line 13), is of that type!
Before we break down exactly what’s happening on line 42, it’s important to note that when passing callbacks to JavaScript event listeners from Rust, we are only able to use closures. There are some interesting problems that arise when we get to handling complex data structures with Rust/JavaScript event listeners because we have to use closures, but we’ll get into some of that later on.

On line 42 we’re creating a closure that accepts a web_sys::Event, retrieves the name property off of our form data, and logs it in the console using web_sys::console.
If we submit our form, we should see something that looks like this:

User form with logged output - titled "User Form" with a "name" label and field and a submit button. The string in the name field is "Arya." Below the form there is console log output that relates to previous submissions that is 1. "jon" 2. "sansa" and 3. "arya"

At this point, we aren’t doing anything reactive, we’re just responding to events with console logs; the interesting reactive behavior shows up in the next two phases of the lifecycle.

Calculate

At this point we have a template and an event listener that responds to form submission. Right now, we are just logging that interaction in the console, but we want to build our UI in such a way that our user doesn’t need to reference the console to see their submission history — we want our user to see the history in the UI.

To do this, we first need to decide how we’re going to manage the form’s state. In a previous post, we took a more object oriented approach — for this form, we’re going to roll with something a little more functional.

The first thing we need to do is add a history argument to our template generator gen_template(). Our new function signature should look something like this: gen_template(name: &str, history: &Vec<String>). We’re choosing to use a Vec (vector) here, because we don’t have a fixed set of entries.

Our final gen_template() function should look like this:

fn gen_template(name: &str, history: &Vec<String>) -> String {
    let history_template: String = history
        .iter()
        .fold(String::new(), |acc, curr| {
            format!("{}<p>{}</p>", acc, curr)
        });

    format!(
        "
            <h1>User Form</h1>
            <label for=\"name\">Name</label>
            <input type=\"text\" id=\"name\" name=\"name\" value=\"{}\">
            <input id=\"submit\" type=\"submit\" value=\"Submit\">
            <section id=\"user-history\">
                {}
            </section>
        ",
        name, history_template,
    )
}
Enter fullscreen mode Exit fullscreen mode

From here we need to update our init_form() function to also accept a history argument. The reason for this — if not already clear— is that we’re going to need our init_form() function in our submit handler to regenerate our form once we’ve received the new submission.

Given that this is a more functional approach, we won’t be mutating a long lived data structure, or modifying the state of elements in the DOM — we will instead reconstruct / re-render our component when the state changes.

Before making our final changes to the init_form() function, we will need to add the serde-serialize feature to wasm_bindgen that will allow us to serialize and de-serialize our vector in and out of JavaScript. Update the wasm_bindgen crate import in the Cargo.toml to look like this:

wasm-bindgen = {version = "0.2.45", features = ["serde-serialize"]}
Enter fullscreen mode Exit fullscreen mode

Now we’ll update our init_form() function to take a history: &JsValue argument:

pub fn init_form(name: &str, history: &JsValue) {
    let history: Vec<String> = history.into_serde().unwrap();
    let window = web_sys::window().expect("global `window` should exist");
    let document = window.document().expect("should have a `document` on `window`");

    let root = document
        .get_element_by_id("root")
        .expect("page `root` exists");
    root.set_inner_html("");

    let form_node: web_sys::Element = document
        .create_element("form")
        .expect("DOM element to have been created");

    let template: &str = &gen_template(name, &history);
    form_node.set_inner_html(template);

    let form_node = add_submit_handler(form_node);

    root.append_child(&form_node).expect("`form` to have been appended to `root` node");
}
Enter fullscreen mode Exit fullscreen mode

And our form.js file to pass in an initial value for the history argument:

import { init_form } from "../pkg/index.js";

init_form("Taylor", []);
Enter fullscreen mode Exit fullscreen mode

Explanation: What we have done in each of these files, is allow a history argument to be passed into our init_form() and gen_template() functions. Our init_form() function accepts an arbitrary &JsValue to be parsed by the wasm_bindgen into_serde() function which is made available by the serde-serialize feature.

In our template generator, we are iterating over the history vector to generate another component of the template. We then interpolate our history_template into our final output String.

In our form.js file, we are now passing an empty array as the second argument — in this location, we could also retrieve the history from the network or put in an arbitrary list of names. Something to note is that because JavaScript does not required a predefined length for its arrays, we are able to pass JavaScript array values into Rust and they can still be parsed to Rust Vecs.

Render

Now we get to our final step; recreating the form based on the new state generated by form input. We will be working in our add_submit_handler() function to transition our web_sys::console::log_1() into new form creation with init_form(). Because we’re dealing with a Rust closure, we do have to get creative with how we pass our new state between these two functions. We have also set our init_form() history parameter to accept an &JsValue which means we will need to serialize the updated state into &JsValue before passing through.

Our final add_submit_handler() function should look like this:

fn add_submit_handler(form_node: web_sys::Element, mut history: Vec<String>) -> web_sys::Element {
    let closure = Closure::wrap(Box::new(move |event: web_sys::Event| {
        event.prevent_default();

        let target = event.current_target().unwrap();
        let form = target.dyn_ref::<web_sys::HtmlFormElement>().unwrap();
        let data = web_sys::FormData::new_with_form(form).unwrap();

        let name: String = data
            .get("name")
            .as_string()
            .expect("`name` to exist in form data");


        history.push(String::from(&name));
        let js_val_history = &JsValue::from_serde(&history).unwrap();

        init_form(&name, js_val_history);
    }) as Box<dyn FnMut(_)>);

    let cb = closure.as_ref().unchecked_ref();

    form_node
        .add_event_listener_with_callback("submit", cb)
        .expect("`submit_handler` to have been added");
    closure.forget();
    form_node
}
Enter fullscreen mode Exit fullscreen mode

We’ll also need to pass the history argument into our add_submit_handler() function in the init_form() function. The new form_node reassignment should look like let form_node = add_submit_handler(form_node, history).

When a user is submitted, you should now be able to see them show up in a list below the form:

User form with rendered output - titled "User Form" with a "name" label and field and a submit button. The string in the name field is "Arya." Below the form there is rendered output that relates to previous submissions that is 1. "jon" 2. "sansa" and 3. "arya"

Explanation: The only change we’ve made here is to swap out our web_sys::console::log_1() out for a new form initialization. In order for our init_form() function to receive the correct arguments after we’ve pushed the new name in, we need to convert the history Vec into an &JsValue type (line 16); from here all we need to do is call the init_form() which will generate our template and add the submission handler for us.

Long Term

Now that we’ve covered a high level overview, walked through a basic form implementation and seen what this looks like in action, there are a lot of potential steps to take from here. The goal — as I stated in the introduction — of this discussion is to make Rust and WebAssembly more accessible to front-end developers and the web development world as a whole.

Based on the approach we’ve discussed, the fact that we can respond to events with fully built HTML instead of JSON or JavaScript objects, lends itself to some potentially exiting opportunities. Because the process of injecting HTML can be the same regardless of whether or not the pre-built HTML is provided by a WebAssembly module, or served by a Web Server, there is lots to be explored in the realm of hybrid SSR + reactive client side application, development.

Additionally, by rebuilding our component’s HTML on each render, we have the potential to scale this approach up to a full web application without ever needing to implement a Virtual DOM.

As it continues to mature, I believe we will see more and more that there is a much broader set of things to be done with WebAssembly and Rust, other than just moving the expensive tasks out of our JavaScript.

Discussion (1)

Collapse
seanwatters profile image
Sean Watters Author

Also, a link to my original Medium post here.