Ok(Blog)

Rust TodoMVC

Input Controls

January 04, 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.

Making It More Useful

In the last episode we created a few reusable control components and refactored our ListItem to use them so that we could start having a more interactive app. in this part we will be adding a new text input so that we can add new todo items to our list dynamically.

let's get started and create a new src/components/form/input.rs file. you guessed it, with all the same yew Component boilerplate we know and love, at this point. again, we will make a pretty generic component.

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

notice this time we are importing a new yew type InputData. this is what is used to deal with oninput listener. we will use this InputData in our Message Enum and our self.link.callback in the oninput prop.

continuing on

#[derive(Properties, Clone, Debug)]
pub struct InputProps {
  #[prop_or(String::new())]
  pub initial_value: String,
  #[prop_or(String::new())]
  pub value: String,
  #[prop_or(String::new())]
  pub label: String,
  #[prop_or(String::new())]
  pub placeholder: String,
  #[prop_or(String::new())]
  pub class: String,
  #[prop_or(Callback::noop())]
  pub handle_change: Callback<String>,
}

pub struct Input {
  link: ComponentLink<Self>,
  props: InputProps,
  value: String,
}

pub enum InputMsg {
  UpdateValue(InputData)
}

impl Component for Input {
  type Message = InputMsg;
  type Properties = InputProps;

  fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
    let value = if !props.value.is_empty() {
      props.value.clone()
    } else {
      props.initial_value.clone()
    };

    Self {
      link,
      props,
      value,
    }
  }

  fn update(&mut self, msg: Self::Message) -> ShouldRender {
    match msg {
      InputMsg::UpdateValue(change) => {
        self.value = change.value.clone();
        self.props.handle_change.emit(self.value.clone());
      },
    }
   
    true
  }

  fn change(&mut self, props: Self::properties) -> ShouldRender {
    if self.props.value != props.value {
      self.props.value = props.value.clone();
      self.value = props.value;

      true
    } else {
      false
    }
  }

  fn view(&self) -> html {
    let label = self.props.label.clone();
    let placeholder = self.props.placeholder.clone();
    let classes = self.props.class.clone();

    html! {
      <div class="form-group input-group">
        <label>{ label }</label>
        <input
          type="text"
          class=classes
          oninput=self.link.callback(|v: InputData| InputMsg::UpdateValue(v))
          value={ self.value.clone() }
          placeholder={ placeholder }
        />
      </div>
    }
  }
}

and there we have a basic reusable text input following along with the patterns we've been using previously for other components and inputs. and you'll want to expose it in your src/components/form/mod.rs.

mod checkbox;
mod input;

pub use checkbox::Checkbox; 
pub use input::Input;

let's head on over to our Index view and add a new Input for creating todo items. you can just add another separate import for the Input in Index

use crate::components::form::Input;

or since we are already import some components we can use a slightly different import syntax to have a single import if we do a slight refactor.

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

it's up to you how you would like to do this. i just want to show an optional import syntax that may, at first, not be so obvious. now, in our view we can add our Input

fn view(&self) -> html {
  html! {
    <>
      <header class="header">
        <h1>{ "todos" }</h1>
        <Input
          class="new-todo"
          value=self.current_todo.clone()
          placeholder="what needs to be done?"
          handle_change=self.link.callback(IndexMsg::InputChange)
        />
      </header>
      <section class="main">
        <List class="todo-list">
          <ListItem class="todo" item={ "Learn Rust" } />
          <ListItem class="todo" item={ "Learn Yew" } />
        </List>
      </section>
    </>
  }
}

you'll notice now we need a few more things.

  1. a Message for handling input change
  2. current_todo state

let's add those now. for the Message we will add a new InputMsg Enum and for current_todo a new field on our original Index struct.

add Message type

pub enum IndexMsg {
  InputChange(String),
}

at the top of our impl we will also need to change the type Message = (); to now use our new IndexMsg Enum

impl component for Index {
  type Message = IndexMsg;
  type Properties = ();

  // rest of the code
}

add current_todo field to our Index struct as a String type.

pub struct Index {
  link: ComponentLink<Self>,
  current_todo: String,
}

change our create to initialize the current_todo state

fn create(_props: Self::Properties, link: ComponentLink<Self>) -> Self {
  Self {
    link,
    current_todo: String::new(),
  }
}

and finally we will change our update function

fn update(&mut self, msg: Self::Message) -> ShouldRender {
  match msg {
    IndexMsg::InputChange(input) => {
      self.current_todo = input;
    },
  }

  true
}

remember to remove the prepended _ from the parameter _msg. and with that we should have a semi-working input for creating new todo items. note if at any point you want to log values to the web browser console you can use yew::services::ConsoleService this service provides a few functions including log, info, warn, etc. imported in this way you can then log to the console with ConsoleService::log(&format!("message {}", value));. You will also need to enable this feature in your Cargo.toml by adding

yew = { version = "0.17", features = ["services"] }

for instance, if you use the log to log our current_todo we should now see that it log's the value of our Input as we type. at the top of the view you can add ConsoleService::log(&format!("current todo: {}", self.current_todo.clone()));

Even though we have an Input we still don't have a collection of data to add new items to or iterate over for our list. Let's continue editing our Index view with some more features to allow us to actually add new items to a list.

We'll just use a rust Vec for our items list and we'll create a new type to represent the data our Vec will contain. Somewhere at the top of Index let's add our ItemData struct

struct ItemData {
  pub id: u32,
  pub name: String,
  pub complete: bool,
}

That should be fine for now. let's add a new items field to our Index struct

pub struct Index {
  link: ComponentLink<Self>,
  current_todo: String,
  items: Vec<ItemData>,
}

So our items will be a Vec of type ItemData. Let's continue with just returning a new empty Vec as the initial state of our items in the create function.

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

iLater on we will need a for more utility functions for our Index struct, but they don't need to go in our impl of the yew Component trait. We can create a new impl just for the Index struct like you would do normally in rust. Let's add this after our impl Component for Index at the very bottom. Here we can add a new function that we can call on our component instance to render the items from our list.

impl Index {

  fn render_items(&self) -> Vec<Html> {
    self.items.iter()
      .map(|litem| {
        let ItemData { name, id, complete } = litem;

        html! {
          <ListItem
            key={ *id as i128 }
            id=id
            class="todo"
            item=name
            complete=complete
          />
        }
      }).collect::<Vec<Html>>()
  }
}

So, there's a lot of new things going on here. It's mostly just rust. If you are familiar with react it may look somewhat similar. Typically in react you will map over an array or collection of items and then return jsx. We're doing the same basic thing here. Before we can map a Vec in rust we need to create an interator with .iter(). And after we are done iterating we need to collect the result back into a Vec this time we are also explicitly converting the results from a Vec<ItemData> into a Vec<Html> we are able to do this easily since each iteration of our map is returning some html!. Pretty neat!

The let ItemData { name, id, complete } = litem; is just a way to destructure a struct in rust.

As of now this doesn't really change anything. We still need to call this function in our view of the impl Component above. Let's see what that looks like now

fn view(&self) -> Html {
  let items = self.render_items();

  html! {
    <>
      <header class="header">
        <h1>{ "todos" }</h1>
        <Input
          class="new-todo"
          value=self.current_todo.clone()
          placeholder="What needs to be done?"
          handle_change=self.link.callback(IndexMsg::InputChange)
        />
      </header>
      <section class="main">
        <List class="todo-list">
          { items }
        </List>
      </section>
    </>
  }
}

In our view we can just call self.render_items(); and assign the returned value Vec<Html> to a new variable and just put that variable anywhere in our html! and yew will know how to handle the collection properly. That's all great, but now we still can't add new todo items and now our list is empty since we removed the hard coded values. If you'd like to add some temporarily you can use a vec! with some items in the Self return of the create function.

Self {
  link,
  current_todo: String::new(),
  items: vec![
    ItemData { name: "Learn Rust".to_string(), id: 1, complete: false },
    ItemData { name: "Learn Yew".to_string(), id: 2, complete: false },
  ],
}

Let's add some new code to help us add some items. We'll use a keyboard event for the enter key. I think that's what the classic TodoMVC does. Even if it doesn't it gives us a chance to explore different types of events.

To add a listener for a keyboard event we will need to add an import for yew::prelude::KeyboardEvent; in our existing list of yew imports.

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

We'll also add a new value in our IndexMsg Enum and a new Enum for our KeyCodes. This is not strictly necessary, but it'll be nicer to use Keycode::Enter rather than hard coded numbers like 13 everywhere. It would also be more flexible to change the Keycode value at any point in the future, in one place rather than throughout the code. While we're add it let's add a simple utility function for checking if the Keycode is a specific Keycode.

pub enum IndexMsg {
  InputChange(String),
  Keypress(u32),
}

pub enum Keycode {
  Enter = 13
}

fn is_keycode(value: u32, code: Keycode) -> bool { value == code as u32 }

After that we need to add our match arm for the new IndexMsg in our update function.

fn update(&mut self, msg: Self::Message) -> ShouldRender {
  match msg {
    IndexMsg::InputChange(input) => {
      self.current_todo = input;
    },
    IndexMsg::Keypress(keycode) => {
      match keycode {
        _ if is_keycode(keycode, Keycode::Enter) => {
          let name = self.current_todo.clone();
          self.current_todo = String::new();

          if !name.is_empty() {
            self.items.push(ItemData {
              id: self.internal_id,
              name,
              complete: false,
            });

            self.internal_id += 1;
          }
        },
        _ => {}
      }
    },
  }

  true
}

The logic here is not too bad. Baically when we get the message that the Enter key was pressed we copy the value from the input, we reset our current_todo state to an empty String and then we push this new ItemData into our items Vec if the string is a non-empty value. Similar to Array.push if you are familiar with a language like Javascript.

I added a new field for our Index struct called internal_id. This will just be our poorman way of assigning new ids by simple using a number that increments. In a real application we would probably want to use something more sophisticated, but this should do for our little Todo app. Let's add it to the struct and modify create to initialize it to 0;

pub struct Index {
  link: ComponentLink<Self>,
  current_todo: String,
  items: Vec<ItemData>,
  internal_id: u32,
}

And in our create return of Self we can just update it to return 0 for now.

Self {
  link,
  current_todo: String::new(),
  items: Vec::new() // or if you have some hard coded ItemData here you can keep those for now
  internal_id: 0,
}

The final thing we need is to add a onkeypress event listener on our header in the view function.

<header
  class="header"
  onkeypress=self.link.callback(|e: KeyboardEvent| IndexMsg::Keypress(e.key_code()))
>

The e.key_code() part is actually nearly identical to how the browser API works. This will be common as the entire window / browser API is used by yew and can also be imported with cargo by us, using the web-sys package.

And with that if we build and run we can see the text input should be adding a new item to our list after we type and then hit the Enter key.

This was quite a long section of the tutorial, but we still have a lot more to go. In the next section we will tackle removing and completing items.

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

part one | part two | part three | part five