This version of the documentation is outdated, and features documented here may work differently now. You can see the latest stable version of the docs here.

Freezing to IndexedDB

One of the most common places to store frozen state is inside the browser, which can be done with Perseus' inbuilt IdbFrozenStateStore system, which uses IndexedDB to store as many frozen states as you want, allowing you to revert to not just the previous state, but the one before that, the one before that, etc.

To use this system, you'll need to enable the idb-freezing feature flag, and then you can use the system as per the below example.

Example

The following code is taken from here.

use perseus::state::{Freeze, IdbFrozenStateStore, PageThawPrefs, ThawPrefs};
use perseus::{Html, RenderFnResultWithCause, Template};
use sycamore::prelude::*;

use crate::global_state::AppStateRx;

#[perseus::make_rx(IndexPropsRx)]
pub struct IndexProps {
    username: String,
}

#[perseus::template_rx]
pub fn index_page(state: IndexPropsRx, global_state: AppStateRx) -> View<G> {
    let username = state.username;
    let username_2 = username.clone(); // This is necessary until Sycamore's new reactive primitives are released
    let test = global_state.test;
    let test_2 = test.clone();

    // This is not part of our data model
    let freeze_status = Signal::new(String::new());
    let thaw_status = Signal::new(String::new());
    let render_ctx = perseus::get_render_ctx!();

    view! {
        // For demonstration, we'll let the user modify the page's state and the global state arbitrarily
        p(id = "page_state") { (format!("Greetings, {}!", username.get())) }
        input(id = "set_page_state", bind:value = username_2, placeholder = "Username")
        p(id = "global_state") { (test.get()) }
        input(id = "set_global_state", bind:value = test_2, placeholder = "Global state")

        // When the user visits this and then comes back, they'll still be able to see their username (the previous state will be retrieved from the global state automatically)
        a(href = "about", id = "about-link") { "About" }
        br()

        button(id = "freeze_button", on:click = cloned!(freeze_status, render_ctx => move |_|
            // The IndexedDB API is asynchronous, so we'll spawn a future
            wasm_bindgen_futures::spawn_local(cloned!(render_ctx, freeze_status => async move {
                // We do this here (rather than when we get the render context) so that it's updated whenever we press the button
                let frozen_state = render_ctx.freeze();
                let idb_store = match IdbFrozenStateStore::new().await {
                    Ok(idb_store) => idb_store,
                    Err(_) => {
                        freeze_status.set("Error.".to_string());
                        return;
                    }
                };
                match idb_store.set(&frozen_state).await {
                    Ok(_) => freeze_status.set("Saved.".to_string()),
                    Err(_) => freeze_status.set("Error.".to_string())
                };
            }))
        )) { "Freeze to IndexedDB" }
        p { (freeze_status.get()) }

        button(id = "thaw_button", on:click = cloned!(thaw_status, render_ctx => move |_|
            // The IndexedDB API is asynchronous, so we'll spawn a future
            wasm_bindgen_futures::spawn_local(cloned!(render_ctx, thaw_status => async move {
                let idb_store = match IdbFrozenStateStore::new().await {
                    Ok(idb_store) => idb_store,
                    Err(_) => {
                        thaw_status.set("Error.".to_string());
                        return;
                    }
                };
                let frozen_state = match idb_store.get().await {
                    Ok(Some(frozen_state)) => frozen_state,
                    Ok(None) => {
                        thaw_status.set("No state stored.".to_string());
                        return;
                    }
                    Err(_) => {
                        thaw_status.set("Error.".to_string());
                        return;
                    }
                };

                // You would probably set your thawing preferences differently
                match render_ctx.thaw(&frozen_state, ThawPrefs { page: PageThawPrefs::IncludeAll, global_prefer_frozen: true }) {
                    Ok(_) => thaw_status.set("Thawed.".to_string()),
                    Err(_) => thaw_status.set("Error.".to_string())
                }
            }))
        )) { "Thaw from IndexedDB" }
        p { (thaw_status.get()) }
    }
}

pub fn get_template<G: Html>() -> Template<G> {
    Template::new("index")
        .build_state_fn(get_build_state)
        .template(index_page)
}

#[perseus::autoserde(build_state)]
pub async fn get_build_state(
    _path: String,
    _locale: String,
) -> RenderFnResultWithCause<IndexProps> {
    Ok(IndexProps {
        username: "".to_string(),
    })
}

This example is very contrived, but it illustrates the fundamentals of freezing and thawing to IndexedDB. You'll need to perform most of this logic in a wasm_bindgen_futures::spawn_local(), a function that spawns a future in the browser, because the IndexedDB API is asynchronous (so that costly DB operations don't block the main UI thread). The first button we have in this example has its on:click handler set to one of these futures, and it then freezes the state, initializes the database (which will either create it or open it if it already exists), and then calls .set() to set the new frozen state (which will remove previously stored frozen states in the background). The rest of the code here is just boilerplate for reporting successes or failures to the user.

Notably, the operations you'll perform through IdbFrozenStateStore are all fallible, they can all return an Err. These cases should be handled carefully, because there are a myriad number of causes (filesystem errors in the browser, invalid data, etc.). Perseus tries to shield you from these as much as possible, but you should be wary of potentially extremely strange errors when working with IndexedDB (they should be very rare though). If your app experiences an error, it's often worth retrying the operation once to see if it works the second time. If you're having trouble in local development, you should use your browser's developer tools to delete the perseus database.

As for thawing, the process is essentially the same, except in reverse, and it should be noted that the .thaw() method is fallible, while the .freeze() method is not. This is due to the potential issues of accepting a frozen state of unknown origin.

One thing that may seem strange here is that we get the render context outside the click handlers. The reason for this is that the render context is composed almost entirely of Signals and the like, so once you have one instance, it will update. Further, we actually couldn't get the render context in the futures even if we tried, since once we go into the future, we decouple from Sycamore's rendering system, so the context no longer exists as far as it's concerned. We can work around this, but for simplicity it's best to just get the render context at the beginning and use it later.

It's also important to understand that we don't freeze straight away, but only when the user presses the button, since the result of .freeze() is an unreactive String, which won't update with changes to our app's state.