Helper state

For a long time, the Perseus state platform consisted only of what you've read about so far, but there was a problem with this, one that's quite subtle. Let's say you have a blog where posts can be organized into series, and then there's a series template that lists each series in order. How would you write the state generation code for the series template? (Assuming it can all be done at build-time, for simplicity.)

Well, you might think, we can iterate over all the blog posts in the build paths logic, and read their series metadata properties to collate a list of all the series, so that's the first part done. (Right on!) And then for the actual build state generation, you'd just need to find all the blog posts that are a part of the given series. But how can we do that?

The best way is to iterate through all the blog posts again, which means, since the builds for all the series pages are done in parallel, if you have ten series, you're iterating through all those posts and reading every single one of them eleven times (+1 for the build paths logic). This is totally unreasonable, especially if your blog posts are on a server, rather than a local directory, and this could massively slow down build times. What would be good is if we could somehow only iterate through everything once, and just store a map of which posts are in what series that we can share through all the actual build state generations.

Because the only solutions to this problem are ugly workarounds, we decided to implement this as a first-class feature in Perseus: helper state! This is what that generic on StateGeneratorInfo is all about: it denotes the type of your helper state.

Importantly, helper state isn't really like any of the other state systems in Perseus, because it's not available to the views you create, and it never gets to the client: it's just a helper for the rest of your state generation. Internally, Perseus calls this extra state, but helper state has come to be its name outside the codebase.

Here's an example of using helper/extra state:

use perseus::prelude::*;
use serde::{Deserialize, Serialize};
use sycamore::prelude::*;

fn index_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a PageStateRx) -> View<G> {
    view! { cx,
        h1 {
            (state.title.get())
        }
        p {
            (state.content.get())
        }
    }
}

// This is our page state, so it does have to be either reactive or unreactive
#[derive(Serialize, Deserialize, Clone, ReactiveState)]
#[rx(alias = "PageStateRx")]
struct PageState {
    title: String,
    content: String,
}

// Notice that this doesn't need to be reactive/unreactive, since it's
// engine-only (note that this helper state is very simple, but you could have
// any `struct` here)
#[derive(Serialize, Deserialize)]
struct HelperState(String);

// In almost every other example, we use `StateGeneratorInfo<()>`, since that
// type parameter is the type of your helper state! (Make sure you don't confuse
// this with your *template* state!)
#[engine_only_fn]
async fn get_build_state(info: StateGeneratorInfo<HelperState>) -> PageState {
    let title = format!("Path: {}", &info.path);
    let content = format!(
        "This post's original slug was '{}'. Extra state: {}",
        &title,
        // We can't directly access `extra`, we use this function call
        info.get_extra().0,
    );

    PageState { title, content }
}

#[engine_only_fn]
async fn get_build_paths() -> BuildPaths {
    BuildPaths {
        paths: vec![
            "".to_string(),
            "test".to_string(),
            "blah/test/blah".to_string(),
        ],
        // Behind the scenes, Perseus converts this into the magical `TemplateState` type,
        // which can handle *any* owned type you give it! Hence, we need to pop a `.into()`
        // on the end of this.
        extra: HelperState("extra helper state!".to_string()).into(),
    }
}

pub fn get_template<G: Html>() -> Template<G> {
    Template::build("index")
        .view_with_state(index_page)
        .build_state_fn(get_build_state)
        .build_paths_fn(get_build_paths)
        .build()
}

Here, we've defined a special extra type called HelperState (but it can be called anything you like), and then we've used that for the extra parameter of BuildPaths. This allows the build paths function, which is executed once, to pass on useful information to the build state systems, potentially reducing the volume of computations that need to be performed. Note the use of .into() on the HelperState to convert it into a Boxed form that Perseus is more comfortable with internally. In fact, it's only when we call .get_extra() on the StateGeneratorInfo provided to the get_build_state function that Perseus performs the conversions necessary to retrieve our helper state type (which means specifying the generic incorrectly can lead to panics at build-time, but these would be caught before your app went live, don't worry). Finally, the .0 is just used to access the String inside HelperState.

That's pretty much all there is to helper state, and it's available at all stages of the state generation process, right up to request-time state. If there are any parts of request-time state that you can do at build-time, this is the best way to do them if you're not using state amalgamation.