Rust TodoMVC
Persistance Makes Storage
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.
Give Me Storage or Give Me... Nothing?
In the last part we used yew-router
to create Filters
for the different states our todo items could be in.
That almost wraps it all up, but we still have that problem of the state not persisting and being wiped out
on refresh. Let's use the browser's sessionStorage
to handle. To use sessionStorage
for our app state we
will add another, really useful and amazing, rust package called serde
and more specifically serde-json
to Serialize
or state back and forth from a String
into our ItemData
struct
ure. We'll also add web-sys
so we can access the browser window
's Storage
API. Now, yew
does have a service
for this that we could use
and maybe we should. But in the spirit of learning. Let's do it ourselves.
Edit Cargo.toml
[dependencies]
# previous dependencies
web-sys = { version = "0.3.44", features = ["Window", "Storage"] }
serde = "1.0.115"
serde_json = "1.0.57"
We add both Window
and Storage
features
for web-sys
. web-sys
is a massive library so you have to opt-in
to any all features you want to use.
Let's create a new folder to some session storage functions. src/api
we'll call it. And add a session.rs
file
and a mod.rs
file. the mod.rs
is simply one line. pub mod session;
. Let's create session.rs
.
use web_sys::{Storage};
use wasm_bindgen::{JsValue};
use serde_json::{Value};
pub fn get_session(key: &str) -> Result<Value, String> {
let window = web_sys::window().expect("Window Error");
let storage = window.session_storage().unwrap().expect("SessionStorage not supported");
let data_str = storage.get_item(key).unwrap().unwrap_or("[]".to_string());
let data = serde_json::from_str(&data_str).unwrap_or(Value::Array(Vec::new()));
Ok(data)
}
pub fn set_session(key: &str, value: &str) -> Result<(), String> {
let window = web_sys::window().expect("Window Error");
let storage = window.session_storage().unwrap().expect("SessionStorage not supported");
storage.set_item(key, value)?;
Ok(())
}
pub fn clear_session() -> Result<(), String> {
let window = web_sys::window().expect("Window Error");
let storage = window.session_storage().unwrap().expect("SessionStorage not supported");
storage.clear();
Ok(())
}
As you can see web_sys
API can get pretty verbose, pretty quickly. There is a lot of multiple unwrap
ing.
Looking at this we could already refactor this and have some kind of simple window
and storage
abstraction
so we aren't having to do this in every function.
Moving on to the next error handling aspect we can use an .expect
after the unwrap
and give it our on
message. And finally we can do unwrap_or()
. This is a way to more gracefully handle an error when we're rather do
something else when there is an error. We can give it a value and that value is what we will use in the case of an Error.
In the above example we're just saying, hey, if you hit an error when trying to get that session storage data just use an
empty Array
or Vec
. Actually it would be a better user experience if instead of an Error
trying to unwrap session_storage
if the user's browser doesn't support session_storage
we may want to let the app continue to work as normal, but that user
just want get the extra experience of having state persisted between refreshes.
But, for this little app the above is fine. Feel free to play around and refactr it. Maybe make some nice helper functions for accessing
window
and session_storage
.
Of course, let's not forget we now need to expose our api
module in the lib.rs
file.
// previous code
mod routes;
mod api;
// previous code
Now, let's go into our Index
view and start using the new api::session
functions.
We'll first need to add the new imports (including serde
).
use yew::prelude::{
// previous imports
};
use serde::{Serialize, Deserialize};
use serde_json::{Value};
// pervious imports
use crate::api::session;
Remember when I mentioned way back when that we would add some more derive
s to ItemData
?
Well here be go. Let's update the Index
view.
#[derive(Serialize, Deserialize, Ord, PartialOrd, Eq, PartialEq)]
struct ItemData {
pub id: u32,
pub name: String,
pub complete: bool,
}
We didn't actually change the ItemData
struct
itself. We just added serde
's Serialize
and Deserialize
Trait
s.
These Trait
s can work wonders. If you are doing anything with json
whether it's with yew
in the browser or just rust
on the server or even using json files for config in you're own game engine. serde
is seriously useful library.
So, first, we should only initialize our items
state as an empty Vec::new()
if we either can't get session storage or
we have yet to add anything in session storage. So let's change our create
function to do just that.
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
let storage = session::get_session(&"items").unwrap();
let item: Vec<ItemData> = serde_json::from_value(storage).unwrap_or(Vec::new());
let last_id = Index::get_last_id(&items);
Self {
link,
props,
items,
current_todo: String::new(),
internal_id: last_id,
}
}
So, for the first time we use an explicit type when assign the variable item: Vec<ItemData>
. This is how
we can let serde_json
know what type of value we want it to Serialize
our storage
data into. It works
because we derive
d Serialize
and Deserialize
Trait
s for our ItemData
struct
.
You may have noticed I also add this last_id
and Index::get_last_id(&items);
which we haven't defined yet.
This is so we can start incrementing our new internal_id
when adding a new todo based on how many items we may
already have when we get them out of storage
. If we didn't do this now and kept it at always starting at 0
then if
we get items
from storage
we'll start giving new todos id
s that other todo items already have. Then when we remove
one of those it will remove all items with that same id
, which is not what we want. There are better ways to do this
that wouldn't require the internal_id
. Using another library that can assign random uuid
s or something like that.
Let's finally add two more function in our regular impl Index
. First, the get_last_id
and then another to store_items
which we will then use anywhere we modify the items
collection to save the changes to session_storage
.
impl Index {
fn store_items(&self) {
let json = serde_json::to_string(&self.items);
match json {
Ok(json_str) => {
session::set_session(&"items", &json_str);
},
Err(e) => {},
}
}
// fn render_items is unchanged
}
Here we again use serde_json
to change our items
collection into a String
. The browser's sessionStorage
only
stores strings as values. Then, since serde_json::to_string
returns a rust Result
. We can handle it yet another way
that we didn't use previously. We can pattern match
for Ok
and Err
. Here if we get an Ok
we know the to_string
worked so our data has been changed into a string representation. So, we can set_session
to set the session_storage
data. If it didn't work we decide to just ignore it and move on. Feel free to add code in the Err
arm to do whatever
you would prefer.
impl Index {
// store_items unchanged
fn get_last_id(items: &Vec<ItemData>) -> u32 {
let max_item = items.iter().max();
match max_item {
Some(max) => { max.id + 1 },
None => { 0 },
}
}
// render_items unchanged
}
Here we are using another common rust type Option
. An Option
is either Some
with a value or None
with no
value. We use the max
function on our items
collection to basically get the highest id
from our items
collection
(the Ord
, PartialOrd
Trait
s comes in to play here). Then we add 1
to that id
to act as the id
we will assign to
any new items that we add. If we don't get an id
and get None
instead we just use 0
. The max
will help if there are any hole's
in our items (from removing them in the UI).
Okay, we already used get_last_id
in the changes to create
. Now let's go update all the places where we
make a change to our items
collection (add, remove, complete). These all happen in the match
arms for our update
function.
fn update(&mut self, msg::Self::Message) -> ShouldRender {
match msg {
IndexMsg::InputChange(input) -=> {
self.current_todo = input;
},
IndexMsg::ToggleComplete(item_id) => {
for item in &mut self.items {
if item.id == item_id {
item.complete = !item.complete;
}
}
self.store_items();
},
IndexMsg::RemoveItem(item_id) => {
self.items.retain(|item| item.id != item_id);
self.store_items();
},
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;
self.store_items();
}
},
_ => {}
}
},
}
true
}
That's it! With that if we build and run we should have a working TodoMVC app with persisted state that can survive a refresh.
I'd like to encourage you to experiement a little more and leave a few exercises up to you the reader.
- We never made the footer text
0 items left
change. - Back in
Filters
we never useactive
prop. We should use this to add aselected
cssclass
name - Maybe use the existing
Button
component we have to add a "Clear Session" functionality? - cleaning up the
session.rs
module? - fix all the rust compiler warnings and clean it up a little more. We mostly have warning for used variables and parameters such
as some of the ones mentioned above and a lot of
link: ComponentLink<Self>
which is required nyyew
to add even if we don't use them directly. - We touched on a lot of what most web apps will need to be useful. However, we didn't ever get to do any
fetch
orHttpRequest
s. Maybe you can find a way to incorporate that? Yew does have aFetchService
. Or maybe you want to go lower and do it yourself usingweb_sys
? Or even make you're own library?
There's a lot of potential with Rust, Yew, and Wasm. And there's still a lot of libraries that could be useful. Maybe try to port over an existing Javascript library into Rust.
I hope that the tutorial was easy to follow along with.
I hope you learned something and got excited about this amazing language and framework.
I hope you had a few light bulb moments, like I did.
And above all else, I hope you had fun and enjoyed yourself.
The final code for this part can be found on tutorial repo branch tutorial/part-seven
part one | part two | part three | part four | part five | part six