Dark mode

TodoMVC - LocalStorage

Let's store & load todos from LocalStorage!


Your app should dynamically persist the todos to localStorage. If the framework has capabilities for persisting data (e.g. Backbone.sync), use that. Otherwise, use vanilla localStorage. If possible, use the keys id, title, completed for each item. Make sure to use this format for the localStorage name: todos-[framework]. Editing mode should not be persisted.

  • We need a new dependency called serde to serialize and deserialize todos to and from JSON since we can only store JSON strings in LocalStorage.

  • serde has built-in support for BTreeMap and many other common Rust items. However containers like BTreeMap are de/serializable only when all their items are also de/serializable. For our case, this means we need to enable serde support for Ulid and Todo. Fortunately the ulid crate has built-in serde support - we just need to enable the required feature "serde". You'll find available features in the crate docs or you can look at Cargo.toml or search through issues. Enabling serde support for the most custom items (like our Todo struct) is easy - just derive Deserialize and Serialize.


serde = "1.0.112"
strum = "0.18.0"
strum_macros = "0.18.0"
ulid = { version = "0.3.3", features = ["serde"]


use serde::{Deserialize, Serialize};
use strum_macros::EnumIter;
const ESCAPE_KEY: &str = "Escape";

const STORAGE_KEY: &str = "todos-seed";

fn init(_: Url, _: &mut impl Orders<Msg>) -> Model {
    Model {
        todos: LocalStorage::get(STORAGE_KEY).unwrap_or_default(),

#[derive(Deserialize, Serialize)]
    struct Todo {

fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
    match msg {
    LocalStorage::insert(STORAGE_KEY, &model.todos).expect("save todos to LocalStorage");

Note: Yes, we insert todos into LocalStorage on each message. I don't see any performance problems like the UI freezing or annoying delays during typing. Less code means less bugs and issues should be resolved only when they arise. However, if it becomes a problem, there are some potential solutions:

  1. Update LocalStorage todos only in some match arms.

    • This would help, but be error-prone - you'll forget to add the updating code in a new/updated arm sooner or later. It would also introduce boilerplate and therefore reduce readability.
  2. You can save todos hash into Model (BTreeMap implements Hash) and generate a new one on each message. You'll update LocalStorage todos only if those hashes are different. (See how to calculate hash in the example unsaved_changes.)

    • I assume that serialization, data transfer from Rust to JS world and saving into LocalStorage are the bottleneck. If hashing is slow, we would make it worse. It would need benchmarks.
  3. Write / pick a smarter container instead of BTreeMap (or write a wrapper). It would allow you to implement synchronization with LocalStorage and mitigate problems from the solution 1).

  4. Apply debouncing or throttling to LocalStorage updates.

  5. Integrate manual saving and show something like "Do you want to leave? Data won't be saved." when the user wants to leave/close the browser tab (see example unsaved_changes to learn how to implement it).

    • This would reduce UX in our TodoMVC, however there are use-cases where it would improve UX.
    • This would also make our app less robust - there is a higher probability of losing changes.
  6. Compress stored data.

    1. Currently id is saved twice per each todo - BTreeMap key and id in the Todo struct. We can save it as [[id, title, completed], ..] instead.

    2. Then, we can apply a compressing algorithm and save it as a big string.

    3. It would need benchmarks to prove that additional computations mitigate slow transfer and saving.

I hope you are as happy as me - our app is working and LocalStorage integration wasn't too hard. Let's learn about routing and finish our app by proper filter implementation.