Ok(Blog)

Rust TodoMVC

Routes and Filters

January 07, 2021

This tutorial will guide you through the basics of using Rust along with wasm and Yew to build a basic TodoMVC web app. Building on this in later tutorials to start building more complex web apps utilizing Rust for both the client and server.

Routes as Filters

In the last part we added the rest of the functionality for completing and removing items from our todo list. Now we will add filters for our list using the yew router. With that being said, let's go open our Cargo.toml file and add the dependencies for yew-router 0.14.0.

[package]
name = "yew-todo-mvc"
version = "0.1.0"
authors = ["rusttodomvc"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
yew = "0.17"
yew-router = "0.14.0"
wasm-bindgen = "0.2.67"

First let's set up a simple Enum for our filter states inside the src/app.rs component. We could potential put it somewhere else, but several different components will be using this Enum. Somewhere near the top of the src/app.rs file add

#[derive(Copy, Clone, PartialEq)]
pub enum AppFilter {
  All,
  Active,
  Complete,
}

Let's create a new src/routes folder and a new mod.rs file there. This will be the basic top level route definition for our app and also the first place we will use our new AppFilter Enum.

use yew_router::{Switch, router::Router};
use yew::prelude::{Html, html};

use crate::app::{AppFilter};
use crate::views::{Index};

#[derive(Switch, Clone)]
pub enum AppRoute {
  #[to = "/complete"]
  Complete,
  #[to = "/active"]
  Active,
  #[to = "/"]
  Index,
}

pub fn router() -> Html {
  html! {
    <Router<AppRoute, ()>
      render = Router::render(|route: AppRoute| {
        match route {
          AppRoute::Active => html! { <Index filter=AppFilter::Active /> },
          AppRoute::Complete => html! { <Index filter=AppFilter::Complete /> },
          AppRoute::Index => html! { <Index filter=AppFilter::All />,
        }
      })
    />
  }
}

This is a basic router setup using yew-router. If you're familiar with react-router it's familiar similiar just more rust than javascript. Right now it looks kind of weird though? When we match a route we are just return our Index component for every route. Yes, we will always be rendering that same Index component. What we will use the router for will be to pass in a filter prop to our Index view. Our Index view will then use that value to filter it's items collection based on the route. Router as state.

Let's go add the filter prop now and also the, well, actual filtering. In src/views/index.rs add the import for our new AppFilter where our other imports are and all Properties to our yew import like we have done with other Components that receive props.

use yew::prelude::{
  Component,
  ComponentLink,
  Html,
  html,
  ShouldRender,
  KeyboardEvent,
  Properties,
};

use crate::components::{
  form::Input,
  todo::{List, ListItem},
};

use crate::app::AppFilter;

And under our definition for ItemData we can add the new IndexProps.

#[derive(Properties, Clone)]
pub struct IndexProps {
  pub filter: AppFilter
}

Notice here we did not use our usual #[prop_or(Default)] attributes for the field. This means the filter prop is required when using the Index view. Now we need to setup the things we always need when using Properties for a yew component.

Our props field on our Index struct

pub struct Index {
  link: ComponentLink<Self>,
  props: IndexProps,
  current_todo: String,
  items: Vec<ItemData>,
  internal_id: u32,
}
  • Add the associated type in the impl Component for Index
  • Add props as input and output in the create
  • Update the change function to account for the props
impl Component for Index {
  type Message = IndexMsg;
  type Properties = IndexProps;

  fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
    Self {
      link,
      props,
      current_todo: String::new(),
      items: Vec::new(),
      internal_id: 0,
    }
  }

  fn update(&mut self, msg: Self::Message) -> ShouldRender {
    // code stays the same as before
  }

  fn change(&mut self, props: Self::Properties) -> ShouldRender {
    if self.props.filter != props.filter {
      self.props.filter = props.filter;
      true
    } else {
      false
    }
  }

  fn view(&self) -> Html {
    let items = self.render_items(&self.props.filter);
    // rest of code stays the same as before
  }
}

And finally we need to actually filter our items which happens in our render_items function

fn render_items(&self, filter: &AppFilter) -> Vec<Html> {
  self.item.iter()
    .filter(|item| {
      match filter {
        AppFilter::Active => !item.complete,
        AppFilter::Complete => item.complete,
        AppFilter::All => true,
      }
    })
    .map(|litem| {
      // map code stays the same
    }).collect::Vec<Html>>()
}

Notice now our render_items function takes in a new parameter filter. We pass in our props.filter and use this to match against items in the collection.

We need to expose our router module to the rest of our code in src/lib.rs just add a mod routes where the other mod declarations are.

We should head back over to our src/app.rs and switch out the Index view for our router function.

use crate::routes::router;

and in the src/app.rs view function

fn view(&self) -> Html {
  html! {
    <div id="todo-app" class="todomvc-wrapper">
      <section class="todoapp">
        { router() }
        <footer class="footer">
          <span class="todo-count">
            <strong>{ self.item_count }</strong>
            { " item(s) left" }
          </span>
        </footer>
      </section>
      <footer class="info />
    </div>
  }
}

If you build and run this code you should at least see our Index view still, but we have no way yet to navigate to the routes. If you try to type the route in the address bar you will see the list is always empty. That's because our state doesn't persist. We'll address that later using sessionStorage.

For now, let's add our Filter controls so we can navigate the states of the list using the router. Let's create a new folder src/components/filter with a mod.rs file.

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

use yew_router::components::{RouterAnchor};

use crate::app::AppFilter;
use crate::routes::AppRoute;

For our imports in our Filter you will notice we are using a new yew-router component and the AppRoute Enum we created in the src/routes/mod.rs file. Continuing on in src/components/filters/mod.rs

#[derive(Properties, Clone, PartialEq)]
pub struct FiltersProps {
  #[prop_or(AppFilter::All)]
  active: AppFilter,
}

pub struct Filters {
  link: ComponentLink<Self>,
  props: FiltersProps,
}

impl Component for Filters {
  type Message = ();
  type Properties = FiltersProps;

  fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
    Self {
      link,
      props,
    }
  }

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

  fn change(&mut self, props: Self::Properties) -> ShouldRender {
    if self.props != props {
      self.props = props;
      true
    } else {
      false
    }
  }

  fn view(&self) -> Html {
    let active = self.props.active;

    html! {
      <ul class="filters">
        <li><RouterAnchor<AppRoute> route=AppRoute::Index>{ "All" }</RouterAnchor<AppRoute>></li>
        <li><RouterAnchor<AppRoute> route=AppRoute::Active>{ "Active" }</RouterAnchor<AppRoute>></li>
        <li><RouterAnchor<AppRoute> route=AppRoute::Complete>{ "Complete" }</RouterAnchor<AppRoute>></li>
      </ul>
    }
  }
}

And that's the Filters. Again, it's not too unlike using react-router just more Rust-y with the types and all. One thing you could also do to arguabley make it a bit more readable or at least less verbose is to create a type alias for the RouterAnchor<AppRoute> and use that instead.

// at the top-level outside your `impl Component for Filters`
type RouteFilter = RouterAnchor<AppRoute>;

And use it in your lis instead

<li><RouteFilter route=AppRoute::Index>{ "All" }</RouteFilter></li>

Note also we use an attribute for our FilterProps so that if we don't pass in an active prop it will default to our normal Index view (unfiltered). You could also impl Default for AppFilter in src/app.rs and then use the #[prop_or_default] instead of #[prop_or(AppFilter::All)]. It's up to you and the use-case.

Make sure to expose the Filter in src/components/mod.rs

mod filters;

pub mod filters::Filters;

Now we need to add these controls to our app.rs where they would normally go in the classic TodoMVC app. Add the import at the top-level of src/app.rs

use crate::routes::router;
use crate::components::Filters;

And in the App view

fn view(&self) -> Html {
  html! {
    <div id="app" class="todomv-wrapper">
      <section class="todoapp">
        { router() }
        <footer class="footer">
          <span class="todo-count">
            <strong>{ self.item_count }</strong>
            { " item(s) left" }
           </span>
           <Filters />
      </section>
      <footer class="info" />
    </div>
    </div>
  }
}

We added our Filters controls in the <footer class="footer"> section. With that we should have a full working TodoMVC app built with Rust + Yew and made possibly from the amazing wasm.

We still have a few issues. We can't reload the page or navigate away and come back. We also can't just go to a specified route by typing in the address bar because our items state gets wiped.

In the next part we will finish everything up and some sessionStorage to persist the app state.

The final code for this part can be found on tutorial repo branch tutorial/part-six

part one | part two | part three | part four | part six | part seven