Optimistic updates

Waiting for the server before showing a change makes the app feel slow. With optimistic updates the change is shown right away, and fixed later only if the server didn't accept it.

Single value: LazyCache

optimistically_change (or optimistically_set) replaces the cached value without a request. Then send the change, and reload the value from the server with force_update:

    use vertigo::{bind, dom, get_driver, transaction, AutoJsJson, RequestBuilder, Resource};

    #[derive(AutoJsJson, Clone, PartialEq)]
    pub struct Settings {
        newsletter: bool,
    }

    let settings = RequestBuilder::get("/api/settings").lazy_cache(|status, body| {
        if status == 200 {
            Some(body.into::<Settings>())
        } else {
            None
        }
    });

    let toggle = bind!(settings, |_| {
        // 1. Show the change right away
        settings.optimistically_change(|settings| settings.newsletter = !settings.newsletter);

        let Resource::Ready(new_settings) = transaction(|ctx| settings.get(ctx)) else {
            return;
        };

        // 2. Send it to the server
        get_driver().spawn(bind!(settings, async move {
            let response = RequestBuilder::put("/api/settings")
                .body_json((*new_settings).clone())
                .call()
                .await;

            // 3. Reload from the server: silently if saved, with loading state if not
            settings.force_update(response.status() != Some(200));
        }));
    });

    settings.render(move |settings| {
        let state = if settings.newsletter { "on" } else { "off" };

        dom! {
            <p>
                "Newsletter: " {state} " "
                <button on_click={toggle.clone()}>"Toggle"</button>
            </p>
        }
    })

List: LazyListCache

For lists use LazyListCache, which keeps every item separately and can undo changes of single items. Its methods don't send any requests, they only change what the user sees:

  • optimistically_set_item - adds or changes an item,
  • optimistically_remove_item - hides an item,

and when the server responds:

  • update_item / update_item_with_old_key - saves the item received from the server,
  • remove_item - removes the item for good,
  • rollback - undoes the change (a never saved item gets removed).
    use vertigo::{bind, dom, get_driver, AutoJsJson, CollectionKey, RequestBuilder};

    #[derive(AutoJsJson, Clone, PartialEq)]
    pub struct Todo {
        id: u32,
        title: String,
    }

    #[derive(AutoJsJson)]
    pub struct NewTodo {
        title: String,
    }

    #[derive(PartialEq)]
    pub struct TodoKey;

    impl CollectionKey for TodoKey {
        type Key = u32;
        type Value = Todo;

        fn get_key(todo: &Todo) -> u32 {
            todo.id
        }
    }

    // Temporary id of a todo not saved yet
    const DRAFT_ID: u32 = 0;

    let todos = RequestBuilder::get("/api/todos").lazy_list_cache::<TodoKey>(|status, body| {
        if status == 200 {
            Some(body.into::<Vec<Todo>>())
        } else {
            None
        }
    });

    let add = bind!(todos, |_| {
        let title = "New todo".to_string();

        // Shown right away, under the temporary id
        todos.optimistically_set_item(Todo {
            id: DRAFT_ID,
            title: title.clone(),
        });

        get_driver().spawn(bind!(todos, async move {
            let response = RequestBuilder::post("/api/todos")
                .body_json(NewTodo { title })
                .call()
                .await;

            match response.into(|status, body| (status == 201).then(|| body.into::<Todo>())) {
                // Replaces the draft with the saved todo, keeping its position
                Ok(todo) => todos.update_item_with_old_key(&DRAFT_ID, todo),
                // Removes the draft
                Err(_) => todos.rollback(&DRAFT_ID),
            }
        }));
    });

    let list = todos.render(bind!(todos, |todos_list| {
        dom! {
            <ul>
                {..todos_list.iter().map(|todo| {
                    let id = todo.id;

                    let remove = bind!(todos, |_| {
                        // Hidden right away
                        todos.optimistically_remove_item(&id);

                        get_driver().spawn(bind!(todos, async move {
                            let response = RequestBuilder::delete(format!("/api/todos/{id}"))
                                .call()
                                .await;

                            if response.status() == Some(204) {
                                todos.remove_item(&id);
                            } else {
                                // Brings the todo back
                                todos.rollback(&id);
                            }
                        }));
                    });

                    dom! {
                        <li>{&todo.title} " " <button on_click={remove}>"Remove"</button></li>
                    }
                })}
            </ul>
        }
    }));

    dom! {
        <div>
            {list}
            <button on_click={add}>"Add"</button>
        </div>
    }

See the LazyListCache guide for more.