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.
- a
Message
for handling input change 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 KeyCode
s. 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