Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Leptos

Let's start with the new part, and also typically the shorter part – implementing the capabilities.

Capability implementation

This is what Weather's core.rs looks like

pub type Core = Rc<shared::Core<Weather>>;

pub fn new() -> Core {
    Rc::new(shared::Core::new())
}

pub fn update(core: &Core, event: Event, render: WriteSignal<ViewModel>) {
    log::debug!("event: {event:?}");

    for effect in core.process_event(event) {
        process_effect(core, effect, render);
    }
}

Because both the shell and the core are Rust, the Leptos shell is simpler than the iOS or Android equivalents. There is no need for serialization or foreign function interfaces—the shared types are used directly. The Core is an Rc<shared::Core<Weather>>, and new and update are free functions rather than methods on a class.

We've truncated the process_effect function, but the basic structure is this:

    pub fn process_effect(core: &Core, effect: Effect, render: WriteSignal<ViewModel>) {
        match effect {
            Effect::Render(_) => { /* ... */ }
            Effect::Http(mut request) => { /* ... */ }
            Effect::KeyValue(mut request) => { /* ... */ }
            Effect::Location(mut request) => { /* ... */ }
        }
    }

In Rust we have enums, so we can pattern match and destructure the operation requested. This is the most readable version of effect dispatch across all the shells, since both the core and the shell speak the same language.

We can have a look at what the HTTP branch does:

        Effect::Http(mut request) => {
            task::spawn_local({
                let core = core.clone();

                async move {
                    let response = http::request(&request.operation).await;

                    for effect in core
                        .resolve(&mut request, response.into())
                        .expect("should resolve")
                    {
                        process_effect(&core, effect, render);
                    }
                }
            });
        }

We spawn a local async task via task::spawn_local (WASM is single-threaded, so we use a local future rather than a multi-threaded runtime), then call http::request() to perform the actual HTTP call.

Then it takes the response and passes it to core.resolve, which returns more effect requests. This is perhaps unexpected, but it's the direct consequence of the Commands async nature. There can easily be a command which does something along the lines of:

Command::new(|ctx| {
    let http_req = Http::get(url).expect_json<Counter>().build().into_future(ctx);
    let resp = http_req.await; // effect 1

    let counter = resp.map(|result| match result {
        Ok(mut response) => match response.take_body() {
            Some(counter) => {
                Ok(results)
            }
            None => Err(ApiError::ParseError),
        },
        Err(_) => Err(ApiError::NetworkError),
    });

    let _ = KeyValue::set(COUNTER, counter).into_future(ctx).await // effect 2

    // ...

    ctx.send_event(Event::Done);
})

Once we resolve the http request at the .await point marked "effect 1", this future can proceed and make a KeyValue request at the "effect 2" .await point. So on the shell end, we need to be able to respond appropriately.

What we do is loop through those effect requests (there could easily be multiple requests at once), go through them and recurse—call process_effect again to handle it.

Note that unlike the iOS shell, where resolve returns bytes that need deserialization, in Leptos we call core.resolve() directly and get Effect values back—no serialization boundary to cross.

Just for completeness, this is what http.rs looks like:

use gloo_net::http;

use shared::http::{
    HttpError, Result,
    protocol::{HttpRequest, HttpResponse},
};

#[allow(clippy::future_not_send)] // WASM is single-threaded
pub async fn request(
    HttpRequest {
        method,
        url,
        headers,
        ..
    }: &HttpRequest,
) -> Result<HttpResponse> {
    let mut request = match method.as_str() {
        "GET" => http::Request::get(url),
        "POST" => http::Request::post(url),
        _ => panic!("not yet handling this method"),
    };

    for header in headers {
        request = request.header(&header.name, &header.value);
    }

    let response = request
        .send()
        .await
        .map_err(|error| HttpError::Io(error.to_string()))?;
    let body = response
        .binary()
        .await
        .map_err(|error| HttpError::Io(error.to_string()))?;

    Ok(HttpResponse::status(response.status()).body(body).build())
}

Not that interesting, it's a wrapper around gloo_net's HTTP client for WASM which takes and returns the generated HttpRequest and HttpResponse, originally defined in Rust by crux_http.

The pattern repeats similarly for key-value store and the location capability.

User interface and navigation

It's worth looking at how Weather handles the Workflow navigation in Leptos.

Here's the root component:

#[component]
fn root_component() -> impl IntoView {
    let core = core::new();
    let (view, render) = signal(core.view());
    let (event, set_event) = signal(Event::Home(Box::new(WeatherEvent::Show)));
    let (search_text, set_search_text) = signal(String::new());

    Effect::new(move |_| {
        core::update(&core, event.get(), render);
    });

    view! {
        <>
            <section class="section has-text-centered">
                <p class="title">{"Crux Weather Example"}</p>
                <p class="is-size-5">{"Rust Core, Rust Shell (Leptos)"}</p>
            </section>
            <section class="container">
                {move || {
                    let v = view.get();
                    match v.workflow {
                        WorkflowViewModel::Home { weather_data, favorites } => {
                            let set_event = set_event;
                            view! {
                                <HomeView
                                    weather_data=*weather_data
                                    favorites=favorites
                                    set_event=set_event
                                />
                            }.into_any()
                        }
                        WorkflowViewModel::Favorites { favorites, delete_confirmation } => {
                            let set_event = set_event;
                            view! {
                                <FavoritesView
                                    favorites=favorites
                                    delete_confirmation=delete_confirmation
                                    set_event=set_event
                                />
                            }.into_any()
                        }
                        WorkflowViewModel::AddFavorite { search_results } => {
                            let set_event = set_event;
                            view! {
                                <AddFavoriteView
                                    search_results=search_results
                                    set_event=set_event
                                    search_text=search_text
                                    set_search_text=set_search_text
                                />
                            }.into_any()
                        }
                    }
                }}
            </section>
        </>
    }
}

We create the core with core::new(), then set up two pairs of reactive signals: (view, render) for the view model and (event, set_event) for dispatching events. An Effect::new watches the event signal and calls core::update whenever it changes. The view! macro—Leptos's JSX-like syntax—matches on the WorkflowViewModel enum to decide which child component to render, passing the relevant data and the set_event writer down.

We could do this differently—the core could stay in the root component and we could pass an update callback through Leptos context, and just the appropriate section of the view model to each component. It's up to you how you want to go about it.

Let's look at the HomeView as well, just to complete the picture:

#[component]
fn home_view(
    weather_data: shared::weather::model::current_response::CurrentWeatherResponse,
    favorites: Vec<shared::FavoriteView>,
    set_event: WriteSignal<Event>,
) -> impl IntoView {
    let wd = weather_data;
    let has_data = wd.cod == 200;

    view! {
        <div class="box">
            {if has_data {
                let name = wd.name.clone();
                let desc = wd.weather.first().map(|w| w.description.clone());
                view! {
                    <div class="has-text-centered">
                        <h2 class="title is-4">{name}</h2>
                        <p class="is-size-1 has-text-weight-bold">
                            {format!("{:.1}°", wd.main.temp)}
                        </p>
                        {desc.map(|d| view! {
                            <p class="is-size-5">{d}</p>
                        })}
                        <div class="columns is-multiline is-centered mt-4">
                            <div class="column is-one-third">
                                <p class="heading">{"Feels Like"}</p>
                                <p>{format!("{:.1}°", wd.main.feels_like)}</p>
                            </div>
                            <div class="column is-one-third">
                                <p class="heading">{"Humidity"}</p>
                                <p>{format!("{}%", wd.main.humidity)}</p>
                            </div>
                            <div class="column is-one-third">
                                <p class="heading">{"Wind"}</p>
                                <p>{format!("{:.1} m/s", wd.wind.speed)}</p>
                            </div>
                            <div class="column is-one-third">
                                <p class="heading">{"Pressure"}</p>
                                <p>{format!("{} hPa", wd.main.pressure)}</p>
                            </div>
                            <div class="column is-one-third">
                                <p class="heading">{"Clouds"}</p>
                                <p>{format!("{}%", wd.clouds.all)}</p>
                            </div>
                            <div class="column is-one-third">
                                <p class="heading">{"Visibility"}</p>
                                <p>{format!("{} km", wd.visibility / 1000)}</p>
                            </div>
                        </div>
                    </div>
                }.into_any()
            } else {
                view! {
                    <p class="has-text-centered">{"Loading weather data..."}</p>
                }.into_any()
            }}
        </div>
        {if !favorites.is_empty() {
            view! {
                <div class="box">
                    <h3 class="title is-5">{"Favorites"}</h3>
                    {favorites.into_iter().map(|fav| {
                        let name = fav.name.clone();
                        view! {
                            <div class="box">
                                <strong>{name.clone()}</strong>
                                {if let Some(w) = *fav.current {
                                    view! {
                                        <div class="columns is-multiline mt-2">
                                            <div class="column is-one-third">
                                                <p class="is-size-3 has-text-weight-bold">{format!("{:.1}°", w.main.temp)}</p>
                                            </div>
                                            <div class="column is-one-third">
                                                {w.weather.first().map(|wd| view! {
                                                    <p>{wd.description.clone()}</p>
                                                })}
                                            </div>
                                            <div class="column is-one-third">
                                                <p>{format!("Humidity: {}%", w.main.humidity)}</p>
                                            </div>
                                        </div>
                                    }.into_any()
                                } else {
                                    view! { <p class="has-text-grey">{"Loading..."}</p> }.into_any()
                                }}
                            </div>
                        }
                    }).collect::<Vec<_>>()}
                </div>
            }.into_any()
        } else {
            view! { <div></div> }.into_any()
        }}
        <div class="buttons is-centered mt-4">
            <button class="button is-info"
                on:click=move |_| set_event.set(Event::Navigate(
                    Box::new(shared::Workflow::Favorites(FavoritesState::Idle))
                ))
            >
                {"Favorites"}
            </button>
        </div>
    }
}

It checks whether the weather data has loaded (cod == 200), renders the weather details in a grid using Bulma CSS classes, lists any favorites, and adds a button which when clicked sets the event signal to navigate to the Favorites screen.

This is quite a simple navigation setup in that it is a static set of screens we're managing. Sometimes a more dynamic navigation is necessary, but Leptos Router supports quite complex scenarios in a declarative fashion, so the general principle of naively projecting the view model into the user interface broadly works even there.

There isn't much more to it, the rest of the app is rinse and repeat. It is relatively rare to implement a new capability, so most of the work is in finessing the user interface.

What's next

Congratulations! You know now all you will likely need to build Crux apps. The following parts of the book will cover advanced topics, other support platforms, and internals of Crux, should you be interested in how things work.

Happy building!