Creating Web App in Rust with Yew

I recently had to create a simple webapplication for a project i was working on. Luckily i was free to choose the technology for that task so i decided that it was great oportunity to try out how applicable is Rust for web development. There is already a bunch of mature web servers available such as Rocket or Actix. However as far as i can tell Yew is the only actual framework that allows you to create the frontend application.

Yew gotta try it, man!

According to Yew readme it draws inspiration from Elm and React. I have never used Elm so cannot comment on that. I have used React, however we decided to go our separate ways. I have though used Vue.js quite few times and i really liked it. Maybe because i already used React before and some concept were not new to me anymore. I was happy to realise that Yew resembled Vue in how the app is structured, and its "component oriented design", inter-component message (event) exchange and the..name. Hello!! Vue - Yew ;). Anyway if one tried any of those framework i feel that they would be quite exposed to Yew programming model.

Yew can be compiled to wasm, asm.js or emscripten. In this app i'll be targeting the wasm only though. Yew also supports a Virtual DOM and on every state change it patches parts of the DOM which speed things up giving the user the perception of responsiveness.

The two core components of Yew are Component and Renderable traits. The user is responsible for implementing them. The Component is responsible for mintaining the component state and execution of the business logic. The Renderable trait is responsible for providing the HTML required for rendering of the component on the web page through the virtual DOM.

Example

Yew is still quite new framework even by Rust standards and as it usually is the documentation is limited. There are however some examples available in the project repo. I didn't want to make this example trivial as it usually is with new tech so i wanted to include at least few usefull features that Yew offers.

Those would be:

  • Component properties
  • Component callbacks (events)
  • Request fething
  • Components links

There is still few features that i havent used but sound very usefull such as: Agents and Workers.

The app will fetch the JSON data from a remote server, display it and allow to modify them and send them back to database.

The JSON data have the following structure:

[
    {
        "node_id": "936DA01F9ABD4d4d80C702AF85C822A8",
        "name": "Some Node",
        "location": "Some Place",
        "interfaces":[
            {"interface": "1.1.1.1", "check_method":"Ping", "status": "Down"},
            {"interface": "1.1.1.2", "check_method":"SipPing", "status": "Up"},
            {"interface": "1.1.1.3", "check_method":"Http", "status": "Down"},
            {"interface": "1.1.1.4", "check_method":"Ping", "status": "Up"},
            {"interface": "1.1.1.5", "check_method":"Ping", "status": "Down"},
        ]
    },
    {
        "node_id": "936DA01F9ABD4D9d80C702AF85C822A8",
        "name": "Differnt Node",
        "location": "Different PLace",
        "interfaces":[
            {"interface": "2.1.1.1", "check_method":"Ping", "status": "Down"},
            {"interface": "2.1.1.2", "check_method":"SipPing", "status": "Up"},
            {"interface": "2.1.1.3", "check_method":"Http", "status": "Down"},
            {"interface": "2.1.1.4", "check_method":"Ping", "status": "Up"},
        ]
    },
    {
        "node_id": "936DA01F9ABD5d9d80C702AF85C822A8",
        "name": "Other node",
        "location": "Other Place",
        "interfaces":[
            {"interface": "3.1.1.1", "check_method":"Ping", "status": "Down"},
            {"interface": "3.1.1.2", "check_method":"SipPing", "status": "Up"},
            {"interface": "3.1.1.3", "check_method":"Http", "status": "Down"},
            {"interface": "3.1.1.4", "check_method":"Ping", "status": "Up"},
            {"interface": "3.1.1.5", "check_method":"Ping", "status": "Down"},
        ]
    },
    {
        "node_id": "936DA01F9ABD4d9d80C702AF85C822A4",
        "name": "A node",
        "location": "Another Place",
        "interfaces":[
            {"interface": "192.168.114.1", "check_method":"Ping", "status": "Down"},
            {"interface": "192.168.114.2", "check_method":"SipPing", "status": "Up"},
            {"interface": "192.168.114.3", "check_method":"Http", "status": "Down"},
            {"interface": "192.168.114.4", "check_method":"Ping", "status": "Up"},
            {"interface": "192.168.114.5", "check_method":"Ping", "status": "Up"},
        ]
    },
    ];

Lets get at it

In order to start the work you will need to prepare the dev enviroment for yourself. This section of the Readme file describes the steps. In summary you need to install cargo-web with:

cargo install cargo-web

so that you can build your app with:

cargo web build

for development you will usually want to run:

cargo web start --auto-reload

This will keep the server running and will constantly rebuild after every code change and reload the page for any app that doesn't change the URL location. Please remember that its still Rust so the build step takes few seconds.

The entire app code is available on the Github.

Yew services

Yew comes with a bunch of services to ease up the development. The most commonly used one (I can tell) is going to be the ConsoleService which provides the developer with the mighty log() function.

There are several others however such as: fetch (which we will use later), resize (for screen resizing events), timer (equivalent of javascript setTimeout), storage and many other.

The app

The app has been built with the latest Yew version which is 0.9.0 at the time of writing this. It actually was built with version 0.8.0 but 0.9.0 was released just before publishing this post. Bumping the version caused no issues. I used Bulma to apply a litle bit of styling for the components so that the default HTML wont throw the elements all over the screen. Life is too short for writing CSS by hand!! Lets now have a look at the app itself.

main.rs

use yew_dev_viewer::RootModel;

fn main() {
    yew::start_app::<RootModel>();
}

It starts simple. All we need to do is to use the wrapper to start the Yew application. The RootModel is the main (root) component of our application. Its located in the lib.rs file.

lib.rs

#![recursion_limit = "512"]

mod device;
mod device_modal;
mod devices;

use yew::{html, Component, ComponentLink, Html, Renderable, ShouldRender};

use crate::devices::Devices;

pub struct RootModel;

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

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

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

impl Renderable<RootModel> for RootModel {
    fn view(&self) -> Html<Self> {
        html! {
            <section class="section">
                <div class="continer">
                    <h2 class="title">
                        {"my nodes"}
                    </h2>
                    <Devices />
                </div>
            </section>
        }
    }
}

Here we need implement the 2 mentioned Traits so that Yew can render it after the page load. We set the Message and Properties associated types as () as we wont be passing any properties to this component and there is no specific messages for this Component to handle. The <Devices /> tag is our custom component that is defined in the devices.rs file.

devices.rs

Here the component gets a little more complicated as we will be finally dealing with internal state of the components and we will be sending HTTP requests to external endpoints. This component will fetch the devices list from an endpoint and render that list on the page in a table. Each table row will be clickable and will bring up a modal containing the details about the device.

pub struct Devices {
    devices: Vec<Device>,
    fetch: FetchService,
    link: ComponentLink<Devices>,
    task: Option<FetchTask>,
    modal_visible: bool,
    current_device: Option<Device>,
}

We start from defining the struct that will contain the required items to represent our component functionality. The most interesting fields are:

  • devices field will contain the list of devices once they are succesfully fetched from remote location.
  • fetch is an instance of the FetchService.
  • task will keep a track of currently running fetch requests
  • link allows us to create callbacks that trigger the update trait method and update the current component state
pub enum DevicesMsg {
    FetchOk(Vec<Device>),
    FetchFail,
    ShowDeviceModal(Device),
    HideDeviceModal,
    AddDeviceModal,
}

This enum defines a bunch of events that we will need to handle in the update trait method.

Now lets implement the Component trait onto our struct. The Component trait defines a bunch of method such as: create, update, mounted (added in 0.9.0 release), change, view and destroy. We are required to implement only first two. You can implemnt the rest of them when your application requires it. i.e. the mounted gets called when your component is succesfully attached to the Virtual DOM. The change needs to be implemented if you expect that the properties passed to the component might change and the child needs to apply those new properties.

impl Component for Devices {
    type Message = DevicesMsg;
    type Properties = ();

    fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
        let url =
            "https://r5fccfffwg.execute-api.eu-west-1.amazonaws.com/testing/devices";

        let mut dev = Devices {
            devices: Vec::new(),
            fetch: FetchService::new(),
            task: None,
            link,
            modal_visible: false,
            current_device: None,
        };

        let callback = dev.link.send_back(
            move |res: Response<Json<Result<Vec<Device>, Error>>>| {
                let (meta, Json(data)) = res.into_parts();
                if meta.status.is_success() {
                    match data {
                        Ok(d) => DevicesMsg::FetchOk(d),
                        Err(_) => DevicesMsg::FetchFail,
                    }
                } else {
                    DevicesMsg::FetchFail
                }
            },
        );
        let request = Request::get(url).body(Nothing).unwrap();
        dev.task = Some(dev.fetch.fetch(request, callback));

        dev
    }

    fn update(&mut self, msg: Self::Message) -> ShouldRender {
        match msg {
            DevicesMsg::FetchFail => false,
            DevicesMsg::FetchOk(devices) => {
                self.devices = devices;
                self.task = None;
                true
            }
            DevicesMsg::ShowDeviceModal(device) => {
                self.modal_visible = true;
                self.current_device = Some(device);
                true
            }
            DevicesMsg::HideDeviceModal => {
                self.modal_visible = false;
                true
            }
            DevicesMsg::AddDeviceModal => true,
        }
    }
}

We fech the data right after the component gets created. The fetch method from the FetchService takes in the Request object and the callback. The Request object contains the data of the HTTP endpoint we're trying to connect to (url, data, headers, etc.). We're using the builder to construct our request object. The callback is a closure where we specify how to handle the response from the endpoint (either the returned data or the error). As can be seen we dont deal with the data directly in the callback but we use our previously defined Message to send it to the update method where we will deal with it as required. This is the example of how we can use the messages to communicate between components and within the component itself. The rule of thumb would be that use Properties when communicating Parent -> Child and use callback when the information flows Child -> Parent.

In the update() method we simply implement a behaviour for every message we might receive. Please notice the signature that it takes &mut self as this is the place where you are allowed to modify the state of the component. Returning true or false from the method controls if the component is going to be re-rendered by the Yew.

Now its time to implement the Renderable trait. This trait only defines one method: view(&self) -> Html<Self>. Notice that it only takes in &self so no state mutation for you in here. It return the Html<_> struct. The contained type can be any type that implement the Component trait. This is why we can use Self in here since we already implementer the Component type.

impl Renderable<Devices> for Devices {
    fn view(&self) -> Html<Self> {
        let devices_row = |d: &Device| {
            let interface_state = d.interface_summary();
            let dev = d.clone();
            html! {
                <tr onclick= |_| DevicesMsg::ShowDeviceModal(dev.clone())>
                    <td>{d.node_id}</td>
                    <td>{d.name.clone()}</td>
                    <td>{d.location.clone()}</td>
                    <td>{interface_state.0}{"/"}{interface_state.1}</td>
                    <td>
                      <button class="button is-dark is-small"
                        onclick=|_| DevicesMsg::AddDeviceModal>{"Add node"}
                      </button>
                    </td>
                </tr>
            }
        };

        let device_modal = match self.current_device.as_ref() {
            None => {
                html! {}
            }
            Some(dev) => {
                html! {
                    <DeviceModal: device=dev.clone() on_close=|_|DevicesMsg::HideDeviceModal visible=self.modal_visible/>
                }
            }
        };

        html! {
            <div>
              {device_modal}
              <div class="table-container">
                <h3>{"Devices"}</h3>
                <table class="table is-fullwidth is-bordered is-hoverable">
                  <thead class="thead-dark">
                    <tr>
                      <th>{"Device id"}</th>
                      <th>{"Device name"}</th>
                      <th>{"Device location"}</th>
                      <th>{"State"}</th>
                      <th>{"Actions"}</th>
                    </tr>
                  </thead>
                  <tbody>
                    {for self.devices.iter().map(devices_row)}
                  </tbody>
                </table>
              </div>
            </div>
        }
    }
}

We use the html! macro, coming from the stdweb crate, to define the html code for the component layout. It allows for mixing the HTML with the actual Rust code so we can conditionally render HTML:

    ... snip ...
    
    let device_modal = match self.current_device.as_ref() {
        None => {
            html! {}
        }
        Some(dev) => {
            html! {
                <DeviceModal: device=dev.clone() 
                    on_close=|_|DevicesMsg::HideDeviceModal 
                    visible=self.modal_visible/>
            }
        }
    };

    html! {
        <div>
            {device_modal}
            <div class="table-container">
              
    ... snip ...

Loop over lists of data:

    <tbody>
        {for self.devices.iter().map(devices_row)}
    </tbody>

Hardcode strings into the HTML:

<th>{"Device id"}</th>
<th>{"Device name"}</th>

Or pass in properties to custom components we've created:

html! {
    <DeviceModal: device=dev.clone() 
        on_close=|_|DevicesMsg::HideDeviceModal 
        visible=self.modal_visible/>
}

In this case the view method implementation just renders the list of the devices and attaches an onclick event listener to each row. When the row is clicked a modal with more ditails will be shown on the page. As a general rule we would prefer to keep the event handlers as simple as possible. Lets have a look at the modal component now

device_modal.rs

This component is quite straighforward however it few additional fetures available in Yew. It looks a bit like this: We use the Properties to pass in to the component to set its initial state.

#[derive(Properties)]
pub struct DeviceModalProps {
    #[props(required)]
    pub device: Device,
    #[props(required)]
    pub visible: bool,
    #[props(required)]
    pub on_close: Callback<bool>,
}

The device field is the its device object to be displayed, visible controls visibility of the component and on_close is the callback that gets executed when the modal is closed. As mentioned before callbacks are a mechanism that the child can pass some messages up to the Parent component.

We pass in the Properties in the create function:

impl Component for DeviceModal {
    type Message = DeviceModalMsg;
    type Properties = DeviceModalProps;

    fn create(prop: Self::Properties, link: ComponentLink<Self>) -> Self {
        Self {
            device: prop.device,
            visible: prop.visible,
            on_close: prop.on_close,
            is_editing: false,
            fetch: FetchService::new(),
            task: None,
            link,
        }
    }
    
    ... snip ...

Next is the mentioned previously change method. Since we pass in properties the child might want to force the re-rendering of itself or it might not. So in the change method we can update the state with new properties and force re-rendering if necesssary.

    fn change(&mut self, props: Self::Properties) -> ShouldRender {
        self.visible = props.visible;
        self.device = props.device;
        true
    }

In here we rerender every time the new properties are passed in and we notify that by returning true. This component will also handle the POST request to the (API) endpoint with when the update button is clicked. Ideally one would probably want to handle the fetching and sending of data in a single component but for the sake of simplicity i implemented it in here. The post data is handled by implementing an onsubmit event listener on the <form> element.

    <form onsubmit=|e| {
        e.prevent_default();
        let form_element: Element = e.target()
            .unwrap().try_into().unwrap();
                DeviceModalMsg::SubmitDevice(
                    FormData::from_element(&form_element).unwrap())
    }>

The SubmitDevice message is defined as follows:

pub enum DeviceModalMsg {
    HideModal,
    EditDevice,
    FinishEdit,
    SubmitDevice(FormData),
    SubmitSuccess,
    SubmitFail,
}

The Device type requires a little bit of boiler code to setup to represent the types. It is a Rust after all, right ;) Also in order to use the FormData methd we need to implment the From trait to tell Rust how to build the Device object.

impl From<FormData> for Device {
    fn from(fd: FormData) -> Self {
        let name = match fd.get("device-name").unwrap() {
            FormDataEntry::String(dev_name) => dev_name,
            _ => unreachable!(),
        };
        let location = match fd.get("device-location").unwrap() {
            FormDataEntry::String(dev_location) => dev_location,
            _ => unreachable!(),
        };

        let iface_address = fd.get_all("iface-address");
        let iface_check_method = fd.get_all("iface-check-method");

        let interfaces: Vec<Interface> = iface_address
            .iter()
            .zip(iface_check_method.iter())
            .map(|i| match i {
                (
                    FormDataEntry::String(address),
                    FormDataEntry::String(check_method),
                ) => (address.clone(), check_method.parse().unwrap()),
                (_, _) => (String::new(), CheckMethod::Ping),
            })
            .map(|(a, cm)| Interface {
                interface: a,
                check_method: cm,
                ..Default::default()
            })
            .collect();

        Self {
            name,
            location,
            interfaces,
            ..Device::default()
        }
    }
}

Otherwise one would need to create a event listener for each input form element and capture the data one by one. With larger forms this would extremely cumbersome. With all the above elements in place we have implemented almost everything that can be commonly found in web applications.

Conclusion

In summary i must say that it was a suprisingly enjoyable experience. Rust still can feel (at least to me) quite rigit in certain cases therefore i was surprised how well it worked in the web space. Big credit to the guys behind the Yew project and the stdweb. The magic behind the html! macro is truly glorious.

There are still some issues such as the documentation (as usual) but i hope that blogs such as this one will be able to help out people trying out Yew. Another thing was the mixing of the Rust and HTML, i work on Emacs and i was sometimes experiencing formating issues with some of the HTML code. Also there would show up occasional crypting compilation error message which would usually turned out to be malformed HTML.

Acknowledgments

Thanks very much to u/HenryZimmerman for his feedback.