Developing a Rust and WebAssembly Offline Cycling Navigation PWA: Part 3 Guide

Angus ChiuAngus Chiu
12 min read

I started developing this app two months ago to explore Rust frontend development. Initially, I use the Seed framework, which greatly inspired me with its ELM-like syntax. As a backend developer, I found the Elm Architecture (or TEA, the really useful synonym) particularly useful. It allows developers to write frontend code that is easy to understand, less "magic word" in the program flow, making it easier to follow the logic without delving too deeply into the framework itself. In Seed, understand the usage of several key functions like init(), update(), and a few others, along with the typical pattern for using wasm-bindgen, was sufficient to get things done.

Unfortunately, I discovered that Seed project is no longer maintained (you can see for yourself in Seed main website mentioned in GitHub) and I had trouble locating documents and examples. Hence, I started looking elsewhere and came across this list:

Exploring these projects, I was genuinely amazed at the variety of approaches available for using Rust in frontend development. Some projects introduced immediate mode, treating the display window as a canvas to allow users to design UIs freely, which is excellent for game development. Others reuse useful concepts in web development (HTML, CSS, DOM, etc.) and ideas like virtual DOM and SSR. This approach is more suitable for modern web apps, offering near-native performance and an unparalleled Rust development experience.

Using Yew🌲to replace Seed 🌱

I spent some time familiarizing myself with many of these frontend design methodologies and approaches. Though I did write Node.js web apps using Vue before, I have to admit that now I know more about how much I don't know (Dunning-Kruger effect: people tend to become overconfident as they do not have the knowledge about what they do not know). And I am pretty sure that I probably don't need all the flexibility and 3D rendering performance that game development requires just yet, so I started my project again with Yew.

Function components vs struct components

Yew, in a sense, has been much influenced by the React library in the JavaScript world. When I read through their docs, I even think the Yew community is also following the path that React did, which is whether the project should move from the "struct components" style to the "function components" style. (In React, it is from "class components" to using "hooks".) It confused me a bit at the beginning as I used to see Yew code with a lot of ELM-like structure with create, view, update functions to manage the lifecycle, but in the official documents, it is moving away from this kind of architecture and promoting the use of function components and hooks with use_ prefixes.

I tend to like the ELM structure for having very clear and fine control over the component lifecycle. However, after reading through articles (or this from the React world), I understand the potential issues that persist in the ELM style, such as "Props drilling," tangled code between components due to lifecycle management, and increased difficulty in reusing components. I decided to follow Yew's long-term perspective (promoting the use of function components while keeping struct components for specific use cases or preferences) and migrate my Seed code to Yew, in the function component style.

Restart with Yew app

Yew has a helpful website with plenty of information to get you started. This is a big advantage for an emerging solution trying to gain traction. With so many frontend tools for Rust available, developers often worry about choosing the right ones to succeed.

As a long-term Vue.js user, the color tone and layout of the Yew website reminded me of Vue in its early days. However, that is not important; what is important is that I could get almost all the information I needed to start my work from it, and this is good.

After following the instructions to set up the environment, which include configuring the WebAssembly target, installing trunk, and setting up the VS Code editor to include component snippets, I built my first Yew project using the CLI:

cargo generate --git https://github.com/yewstack/yew-trunk-minimal-template

All well and smooth.

Render map using Leaflet-rs

My first goal is to port my previous Rust code, which relies on the Seed framework, into Yew. To use the function components style, I need to refactor the functions originally built in the ELM style in Seed and use "hooks" to manage the map display.

Hence, it is not particularly difficult to make Leaflet-rs work in Yew. First, I created a component called MainMap, initialized and displayed the map in the web browser. Then, I used a UseState variable model to store the state of the map.

But quickly, I ran into problems. To create a map, leaflet::Map::new insisted that the "Map container not found."

The reason for this error is that in the MainMap function component, the map initiation:

let map = Map::new("map", &options);

will bind to VNode with id="map", but in fact, the VNode only exists after the html macro is run and inserts the <div id="map"></div> tag.

    html! {
    <>
        <div id="map"></div>
    </>
    }

Note: As the html macro (as well as any components in Yew) can only return a single DOM HTML element, you need the fragment tag <> to wrap multiple HTML elements. Otherwise, you'll encounter an error and the code will fail to compile.

However, please keep in mind that the <></> fragment tag is not a standard HTML element and may not be properly interpreted by web browsers. As a best practice, ensure that the syntax remains within the Rust domain and does not reach the final output of the DOM structure.

Initialising map once-and-only-once within a component

The solution is straightforward: we initialize the map AFTER the VNode is rendered. To achieve that, two approaches came to my mind: should I keep the map instance inside the MainMap component? Or should I move the map instance out of the component, say, putting it into the parent App component?

In my opinion, as the sole purpose of the map instance is to control the layout of the map rendered into the <div id="map"></div> tag, it seems more reasonable to keep the activities within a single function component, doesn't it?

Hence, I use the use_effect() hook and place the map initialization logic inside it. However, this approach is incorrect because use_effect() hooks trigger whenever the component re-renders. The map initialization should only run once; otherwise, it will cause errors.

On this, Yew incorporated a good use_effect_with() hook which accepts dependencies, and if you provide no dependencies, it will only run once, exactly what I need.

Hence the code becomes like this:

#[derive(Properties, PartialEq)]
pub struct MainMapProps {
    pub pos: Coord,
}

#[function_component(MainMap)]
pub fn main_map(props: &MainMapProps) -> Html {
    info!("Rendering MainMap with position {:?}", props.pos);
    let mut model = Model::default();
    use_effect_with((), move |_| {
        // use_effect_with hook with empty dependencies ensure this effect runs only once.
        // FnOnce, init map for the MainMap component.
        {
            info!("Initializing map...");
            let options = MapOptions::default();
            let map = Map::new("map", &options);
            let gpx_lg = LayerGroup::new();
            gpx_lg.add_to(&map);
            let position_lg = LayerGroup::new();
            position_lg.add_to(&map);

            // Generate a random start location to initialize the map if geolocation isn't available.
            let mut rng = thread_rng();
            let position = Coord {
                lat: rng.gen_range(-90.0..90.0),
                lon: rng.gen_range(-180.0..180.0),
            };
            // Set the map view to the random position with a default zoom level.
            map.set_view(&LatLng::new(position.lat, position.lon), 10.0);
            // Add a tile layer and a polyline to the map.
            add_tile_layer(&map);
            model.map = Some(map);
        }
        // TeardownFn
        || {}
    });

    html! {
    <>
    <p>{ format!("pos: {:?}", props.pos) }</p>
    <div id="map"></div>
    </>

    }
}

It's all good for now (not until you want to update the map 😛, as there are no hooks to do anything with the map after initialization).

Access Geolocation API using web-sys

As the app should zoom the map to the user's current location, I again use web-sys to access the browser's Geolocation API and acquire the user's current location.

From my previous project that used Seed, I have a Model instance that stores all the state information about the map, including the reference to the map object and the current position. However, it doesn't work well in Yew functions. I couldn't put it into UseState<Model> of the App component and let the child MainMap initialize it and return the map instance.

Instead of creating a bi-directional state transition, I decided to place these callbacks in the App component and return the current GPS coordinates to a pos state hook within the App component. The idea is that whenever the current position changes, I might need to update some components in App, and at the very least, MainMap should be aware of the change and re-render the map.

It took a lot of effort to sort this out, but I'm still glad I'm using Rust. At least the compiler gives me more information about what went wrong and suggests a direction to fix it.

Getting Tripped Up and How I Solved It

In the current design, the App component has a MainMap child component and a UseStateHandle<Coord> called pos to store the callback result from the Geolocation API:

pub fn app() -> Html {
    let pos = use_state(Coord::default); // Use state hook trigger re-rendering when state changes.
    {
        let pos = pos.clone();
        use_effect_with((), move |_| {
            // Use effect_with hook with empty dependencies ensures this effect runs only once.
            let pos = pos.clone();
            // Attempt to access the Geolocation API from the browser's window object.
            let geolocation: Geolocation = window()
                .navigator()
                .geolocation()
                .expect("Unable to get geolocation.");

            // Define a success callback that extracts the latitude and longitude from the Position object,
            let success_callback = Closure::wrap(Box::new(move |position: Position| {
                let position = Coord {
                    lat: position.coords().latitude(),
                    lon: position.coords().longitude(),
                };
                // // pan map to current position
                pos.set(position);

                info!(
                    "1. Geolocation API callback success\n position - {:?}",
                    position
                );
                // Caution and possible cause of empty return: https://docs.rs/yew/latest/yew/functional/fn.use_state.html#caution
                info!(
                    "2. Geolocation API callback success\n pos: UseStateHandle<Coord> {:?}",
                    *pos.clone()
                );
            }) as Box<dyn FnMut(Position)>);

            // Define an error callback that logs any errors encountered while attempting to get the geolocation.
            let error_callback = Closure::wrap(Box::new(move |error: PositionError| {
                info!("Error getting geolocation: {:?}", error);
            }) as Box<dyn FnMut(PositionError)>);

            // Configure geolocation options, enabling high accuracy.
            let mut options = PositionOptions::new();
            options.enable_high_accuracy(true);
            // Request the current position, providing the success and error callbacks, along with the options.
            geolocation
                .get_current_position_with_error_callback_and_options(
                    success_callback.as_ref().unchecked_ref(),
                    Some(error_callback.as_ref().unchecked_ref()),
                    &options,
                )
                .expect("Unable to get position.");

            // Prevent the callbacks from being garbage-collected prematurely.
            success_callback.forget();
            error_callback.forget();
            || ()
        });
    }

    html! {
        <main>
            <MainMap pos={*pos}/>
        </main>
    }
}

Once the App is rendered, the use_effect_with hook will invoke the Geolocation API. As soon as the callback returns (either in success or failure), it sets the use_state hook pos and passes it into MainMap as props.

Since the props passed into MainMap are being updated, Yew triggers re-rendering for MainMap and executes use_effect() (not use_effect_with(), which only triggers during the initial rendering).

But things get complicated when the callback returns.



#[derive(Properties, PartialEq)]
pub struct MainMapProps {
    pub pos: Coord,
}

#[function_component(MainMap)]
pub fn main_map(props: &MainMapProps) -> Html {
    let model = Rc::new(RefCell::new(Model::default()));
    {
        let model = Rc::clone(&model);
        use_effect_with((), move |_| {
            // use_effect_with hook with empty dependencies ensure this effect runs only once.
            // FnOnce, init map for the MainMap component.
            let options = MapOptions::default();
            let map = Map::new("map", &options);
            let gpx_lg = LayerGroup::new();
            gpx_lg.add_to(&map);
            let position_lg = LayerGroup::new();
            position_lg.add_to(&map);

            // Generate a random start location to initialize the map if geolocation isn't available.
            let mut rng = thread_rng();
            let position = Coord {
                lat: rng.gen_range(-90.0..90.0),
                lon: rng.gen_range(-180.0..180.0),
            };
            // Set the map view to the random position with a default zoom level.
            add_tile_layer(&map);
            model.borrow_mut().map = Some(map);
            model
                .borrow_mut()
                .map
                .as_ref()
                .expect("Map should be initialized")
                .set_view(&LatLng::new(position.lat, position.lon), 18.0);

            // TeardownFn
            || {}
        });
    }

    // Use effect_with_deps hook to trigger re-rendering when props.pos changes.
    let model_clone = Rc::clone(&model);
    let pos = props.pos;
    //FIXME: Find out why map is not being added to the model.
    // This is because the use_effect hook runs immediately after the use_effect_with hook, and the model is not updated.
    // It doesn't matter if the model is stored in a ref cell, or via use_state hook.
    // It requires the the VNode to be re-rendered and that the model changes.
    use_effect(move || {
        if let Some(map) = model_clone.borrow().map.as_ref() {
            info!("use_effect - Updating map view...");
            map.set_view(&LatLng::new(pos.lat, pos.lon), 1.0);
        } else {
            info!("use_effect - No Map, skipping update.");
        }
        || {}
    });

    html! {
    <>
    <p>{ format!("pos: {:?}", props.pos) }</p>
    <div id="map"></div>
    </>
    }
}

The problem is that the Map instance, which is initialized by the use_effect_with hook and placed into model the first time MainMap is rendered,

let model = Rc::new(RefCell::new(Model::default()));

could not be found when the use_effect hook is executed. After some investigation, I realized that this is likely because both use_effect and use_effect_with are closures managed by Yew, while the model is within the main_map function. The model is an Rc::RefCell box created within the main_map component function, and thus it is initialized every time. Since the Map instance is only added to model once during the component's lifecycle, subsequent cloning from the use_effect hook will only get the newly initialized model without the Map instance in it.

Finally, I managed to store the model using a use_state hook in MainMap. The code becomes:


#[derive(Properties, PartialEq)]
pub struct MainMapProps {
    pub pos: Coord,
}

#[function_component(MainMap)]
pub fn main_map(props: &MainMapProps) -> Html {
    let model_state = use_state(Model::default);
    {
        let model = model_state.clone();
        // use_effect_with hook with empty dependencies ensure this effect runs only once.
        use_effect_with((), move |_| {
            // FnOnce, init map for the MainMap component.
            let options = MapOptions::default();
            let map = Map::new("map", &options);
            let gpx_lg = LayerGroup::new();
            gpx_lg.add_to(&map);
            let position_lg = LayerGroup::new();
            position_lg.add_to(&map);

            // Generate a random start location to initialize the map if geolocation isn't available.
            let mut rng = thread_rng();
            let position = Coord {
                lat: rng.gen_range(-90.0..90.0),
                lon: rng.gen_range(-180.0..180.0),
            };
            // Set the map view to the random position with a default zoom level.
            add_tile_layer(&map);
            // Clone the current state, modify it, and set the new state
            map.set_view(&LatLng::new(position.lat, position.lon), 18.0);
            let mut new_model = (*model).clone();
            new_model.map = Some(map);
            model.set(new_model);

            // TeardownFn
            || {}
        });
    }

    let model_clone = model_state.clone();
    let pos = props.pos;
    {
        let model = model_state.clone();
        use_effect(move || {
            if let Some(map) = model.map.as_ref() {
                map.set_view(&LatLng::new(pos.lat, pos.lon), 16.0);
            } else {
                info!("5.2b use_effect - No Map, skipping update.");
            }
            || {}
        });
    }

    html! {
    <>
    <p>{ format!("pos: {:?}", props.pos) }</p>
    <div id="map"></div>
    </>
    }
}

The state of the map and the map instance is now managed by the model state handle, which can be accessed by other hooks in the MainMap component. Problem solved!

Thoughts and next step

Compared to the ELM style (similar to "struct component" in Yew or "class component" in React), I find it a bit tricky to effectively use function components. The ownership rules in Rust make it even more challenging. However, once completed, code written in the "function component" style tends to be shorter and less complex.

In the next article, I will explore further and add more features to the codebase, such as reading GPX files and drawing on the map..

References

My GitHub repo

0
Subscribe to my newsletter

Read articles from Angus Chiu directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Angus Chiu
Angus Chiu

CivicTech advocate believe in the future of decentralisations. Gamer, tour cyclist, Triathlon challenger, Rust lover.