Ok(Blog)

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 structure. 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 unwraping. 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 derives 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 Traits. These Traits 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 derived Serialize and Deserialize Traits 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 ids 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 uuids 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 Traits 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.

  1. We never made the footer text 0 items left change.
  2. Back in Filters we never use active prop. We should use this to add a selected css class name
  3. Maybe use the existing Button component we have to add a "Clear Session" functionality?
  4. cleaning up the session.rs module?
  5. 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 ny yew to add even if we don't use them directly.
  6. 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 or HttpRequests. Maybe you can find a way to incorporate that? Yew does have a FetchService. Or maybe you want to go lower and do it yourself using web_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