• Single Page Applications using Rust
  • Original author: Shesh
  • The Nuggets translation Project
  • Permanent link to this article: github.com/xitu/gold-m…
  • Translator: Jianfeng Hou
  • Proofread by: Loststar, Zenblo, Lsvih

Build single-page applications with Rust

WebAssembly (WASM) enables code written in various languages other than JavaScript to run in the browser. You may not have noticed, but all major browsers support WASM and over 90% of the world’s users have browsers that can run WASM.

Since Rust can compile to WASM, is it possible to build SPA (Single Page Applications) using Rust alone without using a Single line of JavaScript code? The short answer is yes! Read on to learn more, or if you can’t contain the excitement, visit the demo site.

We will build a simple e-commerce site called “RustMart”, consisting of two pages:

  • Home page – lists all products that customers can add to their shopping cart
  • Product Details page – Product details are displayed when a product card is clicked

I use this example here because it tests the minimal set of features needed to build a modern SPA:

  • Navigate between multiple pages without reloading the page
  • Initiate a network request without reloading the page
  • Ability to reuse UI components across multiple pages
  • Update components in different layers of the UI hierarchy

To establish

If you have not already installed Rust, please click this link to install it.

Install these Rust tools:

$ cargo install wasm-pack          Compile Rust into WASM and generate JS interop code
$ cargo install cargo-make         # Task runner
$ cargo install simple-http-server Provide a simple server for Assets
Copy the code

Create a new project:

$ cargo new --lib rustmart && cd rustmart
Copy the code

We will use Yew libraries to build UI components. Let’s add this library and wASM dependencies to: Cargo. Toml:

[lib]
crate-type = ["cdylib"."rlib"]

[dependencies]
yew = "0.17"
wasm-bindgen = "0.2"
Copy the code

Create a new file named makefile.toml and add the following code:

[tasks.build]
command = "wasm-pack"
args = ["build"."--dev"."--target"."web"."--out-name"."wasm"."--out-dir"."./static"]
watch = { ignore_pattern = "static/*" }

[tasks.serve]
command = "simple-http-server"
args = ["-i"."./static/"."-p"."3000"."--nocache"."--try-file"."./static/index.html"]
Copy the code

Start building tasks:

$ cargo make build
Copy the code

If you’re not familiar with Rust, I’ve written some beginner’s guides to help you read this article better.

Hello World

Let’s start with a simple “Hello World” example:

Create static/index.html and add the following code:

<! DOCTYPEhtml>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>RustMart</title>
    <script type="module">
      import init from "/wasm.js";
      init();
    </script>
    <link rel="shortcut icon" href="#" />
  </head>
  <body></body>
</html>
Copy the code

Then add these to SRC /lib.rs:

// src/lib.rs
use wasm_bindgen::prelude::*;
use yew::prelude::*;

struct Hello {}

impl Component for Hello {
    type Message = ();
    type Properties = ();

    fn create(_: Self::Properties, _: ComponentLink<Self- > >)Self {
        Self{}}fn update(&mut self, _: Self::Message) -> ShouldRender {
        true
    }

    fn change(&mut self, _: Self::Properties) -> ShouldRender {
        true
    }

    fn view(&self) -> Html {
        html! { <span>{"Hello World!"}</span> }
    }
}

#[wasm_bindgen(start)]
pub fn run_app() {
    App::<Hello>::new().mount_to_body();
}
Copy the code

We’re doing a lot of things here, but obviously we’re creating a new component called “Hello” that will Hello World! Render into DOM. We will learn more about Yew components later.

Start the service task in a new terminal, then load http://localhost:3000 in your browser

$ cargo make serve
Copy the code

Success! It’s just “Hello World,” but it’s written entirely in Rust.

Before moving on, let’s look at components and other SPA concepts.

Componentized thinking

Using components to build UIs and one-way data flows is a paradigm shift in the front-end world. This was a huge improvement in the way we handled UI, and once we got used to it, it was hard to go back to imperative DOM manipulation.

In libraries like React, Vue, Yew, Flutter, and so on, Component has these properties:

  • The ability to compose larger components
  • Props— Passes data and callbacks from this component to its children
  • StateManipulate the component’s local state
  • AppState— Operates on global state
  • Listen for life cycle events such as “Instantiated”, “Mounted in DOM”, etc
  • Perform secondary functions, such as fetching remote data, operating localstorage, and so on

The component is updated (re-rendered) when one of the following occurs:

  • The parent component is re-rendered
  • Propschange
  • Statechange
  • AppStatechange

So, we update the data (Props, State, AppState) and then the UI is updated based on that data, rather than updating the UI mandates when user interactions, network requests, and so on occur. This is what we normally call “UI as a function of state.”

The details will vary from library to library, but this should give you an overall idea. If you’re new, this way of thinking can take a while to “catch on” and get used to.

The home page

Let’s build the home page first. We built the home page as an overall component and then broke it down into smaller reusable components.

Let’s create the following file:

// src/pages/home.rs
use yew::prelude::*;

pub struct Home {}

impl Component for Home {
    type Message = ();
    type Properties = ();

    fn create(_: Self::Properties, _: ComponentLink<Self- > >)Self {
        Self{}}fn update(&mut self, _: Self::Message) -> ShouldRender {
        true
    }

    fn change(&mut self, _: Self::Properties) -> ShouldRender {
        true
    }

    fn view(&self) -> Html {
        html! { <span>{"Home Sweet Home!"}</span> }
    }
}
Copy the code
// src/pages/mod.rs
mod home;

pub use home::Home;
Copy the code

Let’s update SRC /lib.rs to import the home page component:

  // src/lib.rs
+ mod pages;

+ use pages::Home;
  use wasm_bindgen::prelude::*;
  use yew::prelude::*;

- struct Hello {}

- impl Component for Hello {
- type Message = ();
- type Properties = ();

- fn create(_: Self::Properties, _: ComponentLink
      
       ) -> Self {
      
- Self {}
-}

- fn update(&mut self, _: Self::Message) -> ShouldRender {
- true
-}

- fn change(&mut self, _: Self::Properties) -> ShouldRender {
- true
-}

- fn view(&self) -> Html {
- html! { {"Hello World!" } }
-}
-}

  #[wasm_bindgen(start)]
  pub fn run_app() {
- App::
      
       ::new().mount_to_body();
      
+ App::
      
       ::new().mount_to_body();
      
  }
Copy the code

Now, you should see “Home Sweet Home!” Instead of “Hello World!” Render in your browser.

Let’s start by designing the State of this component:

  • We need to store a list of products retrieved from the server
  • Stores products that the user has added to the shopping cart

We create a simple structure to store the details of the Product:

struct Product {
    name: String,
    description: String,
    image: String,
    price: f64,}Copy the code

We then create a new structure, State, that contains the Products field to store the products fetched from the server:

struct State {
    products: Vec<Product>,
}
Copy the code

Here is the full list of changes in the home page component:

  use yew::prelude::*;

+ struct Product {
+ id: i32,
+ name: String,
+ description: String,
+ image: String,
+ price: f64,
+}

+ struct State {
+ products: Vec
      
       ,
      
+}

- pub struct Home {}
+ pub struct Home {
+ state: State,
+}

  impl Component for Home {
      type Message = ();
      type Properties = ();

      fn create(_: Self::Properties, _: ComponentLink<Self>) -> Self {
+ let products: Vec
      
        = vec! [
      
+ Product {
+ id: 1,
+ name: "Apple".to_string(),
+ description: "An apple a day keeps the doctor away".to_string(),
+ image: "/products/apple.png".to_string(),
+ price: 3.65.
+},
+ Product {
+ id: 2,
+ name: "Banana".to_string(),
+ description: "An old banana leaf was once young and green".to_string(),
+ image: "/products/banana.png".to_string(),
+ price: 7.99.
+},
+];

- Self {}
+ Self {
+ state: State {
+ products,
+},
+}
      }

      fn update(&mut self, _: Self::Message) -> ShouldRender {
          true
      }

      fn change(&mut self, _: Self::Properties) -> ShouldRender {
          true
      }

      fn view(&self) -> Html {
+ let products: Vec = self
+ .state
+ .products
+ .iter()
+ .map(|product: &Product| {
+ html! {
+ 
      
+ +
{&product.name}
+
{"$"}{&product.price}
+ +} +}) + .collect(); + + html! { {products} } - html! { {"Home!" } }}}Copy the code

When a component is created, the CREATE lifecycle method is called, and this is where we set the initial state. So far, we have created a list of product mocks and assigned it to the Products field in the state as the initial state. We will retrieve this list later using a network request.

The View lifecycle method is called when the component is rendered. This is where we iterate over the Products field in the state to generate the product card. If you’re familiar with React, this is the same as the Render method, and HTML! Macros and JSX are similar.

Will some random images stored as static/products/apple. The PNG and static/products/banana. The PNG then you will get the following UI:

Let’s implement the “Add to cart” feature:

  • We’re in a new one calledcart_productsThe field records all the products added to the cart
  • We render an “Add to Cart” button for each product
  • Added updates when the “Add to Cart” button is clickedcart_productsLogic of states
  use yew::prelude::*;

+ #[derive(Clone)]
  struct Product {
      id: i32,
      name: String,
      description: String,
      image: String,
      price: f64,
  }

+ struct CartProduct {
+ product: Product,
+ quantity: i32,
+}

  struct State {
      products: Vec<Product>,
+ cart_products: Vec
      
       ,
      
  }

  pub struct Home {
      state: State,
+ link: ComponentLink
      
       ,
      
  }

+ pub enum Msg {
+ AddToCart(i32),
+}

  impl Component for Home {
- type Message = ();
+ type Message = Msg;
    type Properties = ();

- fn create(_: Self::Properties, _: ComponentLink
      
       ) -> Self {
      
+ fn create(_: Self::Properties, link: ComponentLink
      
       ) -> Self {
      let products: Vec<Product> = vec! [ Product { id: 1, name: "Apple".to_string(), description: "An apple a day keeps the doctor away".to_string(), image: "/products/apple.png". To_string (), price: 3.65,}, Product {id: 2, name: "Banana". To_string (), description: "An old banana leaf was once young and green". To_string (), image: "/products/banana.png". To_string (), price: 7.99,},];+ let cart_products = vec! [];

        Self {
            state: State {
                products,
+ cart_products,
            },
+ link,}}- fn update(&mut self, _: Self::Message) -> ShouldRender {
+ fn update(&mut self, message: Self::Message) -> ShouldRender {
+ match message {
+ Msg::AddToCart(product_id) => {
+ let product = self
+ .state
+ .products
+ .iter()
+ .find(|p: &&Product| p.id == product_id)
+ .unwrap();
+ let cart_product = self
+ .state
+ .cart_products
+ .iter_mut()
+ .find(|cp: &&mut CartProduct| cp.product.id == product_id);
+
+ if let Some(cp) = cart_product {
+ cp.quantity += 1;
+ } else {
+ self.state.cart_products.push(CartProduct {
+ product: product.clone(),
+ quantity: 1,
+})
+}
+ true
+}
+}
- true
    }

    fn change(&mut self, _: Self::Properties) -> ShouldRender {
        true
    }

    fn view(&self) -> Html {
        let products: Vec<Html> = self
            .state
            .products
            .iter()
            .map(|product: &Product| {
+ let product_id = product.id;
                html! {
                  <div>
                    <img src={&product.image}/>
                    <div>{&product.name}</div>
                    <div>{"$"}{&product.price}</div>
+ 
                  </div>
                }
            })
            .collect();

+ let cart_value = self
+ .state
+ .cart_products
+ .iter()
+. The fold (0.0, | acc, cp | acc + (cp) quantity as f64 * cp) product. The price));

- html! { {products} }
+ html! {
+ 
      
+ {format! ("Cart Value: {:.2}", cart_value)} + {products} + +}}}Copy the code
  • clone— We deriveProductIn structureCloneInterface, so as long as the user adds the product to the cart, we can clone itProductStored in theCartProductIn the.
  • updateThis method is to update the componentStateOr to perform secondary functions, such as network requests. It uses containing components to support all actionsMessageEnumeration to call. When we return from this methodtrue, the component is rerendered. In the code above, we send one when the user clicks the “Add to Cart” buttonMsg::AddToCartThe message toupdate. inupdateInternal, which adds the product tocart_productIn (if not present) or increase its number.
  • linkThis allows us to register and trigger usupdateCallback to the lifecycle method.

If you’ve used Redux before, Update is like Reducer (for status updates) and Action Creator (for secondary functions), Message is like Action, and link is like Dispatch.

Try clicking the “Add to Cart” button and see the change in “Cart Value” :

To get the data

We will product data from the create function to move to the static/products/products. The json and USES the fetch query API.

[{"id": 1."name": "Apple"."description": "An apple a day keeps the doctor away"."image": "/products/apple.png"."price": 3.65
  },
  {
    "id": 2."name": "Banana"."description": "An old banana leaf was once young and green"."image": "/products/banana.png"."price": 7.99}]Copy the code

Yew exposes common browser apis, such as Fetch, Localstorage, and so on, through modules called “services”. We can use FetchService to initiate network requests. This requires the Anyhow and Serde libraries, let’s install them:

[package] name = "rustmart" version = "0.1.0" authors = ["sheshbabu <[email protected]>"] edition = "2018" Grate-type = ["cdylib", "rlib"] [dependencies] Yew = "0.17"+ anyhow = "1.0.32"
+ serde = {version = "1.0", features = ["derive"]}
Copy the code

We extract Product and CartProduct to SRC /types.rs so that we can share them across files:

use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct Product {
    pub id: i32.pub name: String.pub description: String.pub image: String.pub price: f64,}#[derive(Clone, Debug)]
pub struct CartProduct {
    pub product: Product,
    pub quantity: i32,}Copy the code

We have exposed two constructs and their fields, and derived the Deserialize and Serialize interfaces.

We’ll use the API module pattern and create a separate module called SRC /api.rs to store our fetch logic:

// src/api.rs
use crate::types::Product;
use anyhow::Error;
use yew::callback::Callback;
use yew::format::{Json, Nothing};
use yew::services::fetch::{FetchService, FetchTask, Request, Response};

pub type FetchResponse<T> = Response<Json<Result<T, Error>>>;
type FetchCallback<T> = Callback<FetchResponse<T>>;

pub fn get_products(callback: FetchCallback<Vec<Product>>) -> FetchTask {
    let req = Request::get("/products/products.json")
        .body(Nothing)
        .unwrap();

    FetchService::fetch(req, callback).unwrap()
}
Copy the code

The FetchService API is a bit odd — it takes a request object and a callback as arguments and returns something called “FetchTask”. Here’s a surprising pitfall: If the “FetchTask” is abandoned, the network request is aborted. So we return it and save it in our component.

Let’s update lib.rs to add these new modules to the module tree:

  // src/lib.rs
+ mod api;
+ mod types;
  mod pages;

  use pages::Home;
  use wasm_bindgen::prelude::*;
  use yew::prelude::*;

  #[wasm_bindgen(start)]
  pub fn run_app() {
      App::<Home>::new().mount_to_body();
  }
Copy the code

Finally, let’s update our home page component:

+ use crate::api;
+ use crate::types::{CartProduct, Product};
+ use anyhow::Error;
+ use yew::format::Json;
+ use yew::services::fetch::FetchTask;
  use yew::prelude::*;

- #[derive(Clone)]
- struct Product {
- id: i32,
- name: String,
- description: String,
- image: String,
- price: f64,
-}

- struct CartProduct {
- product: Product,
- quantity: i32,
-}

  struct State {
      products: Vec<Product>,
      cart_products: Vec<CartProduct>,
+ get_products_error: Option
      
       ,
      
+ get_products_loaded: bool,
  }

  pub struct Home {
      state: State,
      link: ComponentLink<Self>,
+ task: Option
      
       ,
      
  }

  pub enum Msg {
      AddToCart(i32),
+ GetProducts,
+ GetProductsSuccess(Vec
      
       ),
      
+ GetProductsError(Error),
  }

  impl Component for Home {
      type Message = Msg;
      type Properties = ();

      fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
- let products: Vec
      
        = vec! [
      
- Product {
- id: 1,
- name: "Apple".to_string(),
- description: "An apple a day keeps the doctor away".to_string(),
- image: "/products/apple.png".to_string(),
- price: 3.65,
-},
- Product {
- id: 2,
- name: "Banana".to_string(),
- description: "An old banana leaf was once young and green".to_string(),
- image: "/products/banana.png".to_string(),
- price: 7.99,
-},
-];
+ let products = vec! [];let cart_products = vec! [];+ link.send_message(Msg::GetProducts);

          Self {
              state: State {
                  products,
                  cart_products,
+ get_products_error: None,
+ get_products_loaded: false,
              },
              link,
+ task: None,
          }
      }

      fn update(&mut self, message: Self::Message) -> ShouldRender {
          match message {
+ Msg::GetProducts => {
+ self.state.get_products_loaded = false;
+ let handler =
+ self.link
+ .callback(move |response: api::FetchResponse
      
       >| {
      
+ let (_, Json(data)) = response.into_parts();
+ match data {
+ Ok(products) => Msg::GetProductsSuccess(products),
+ Err(err) => Msg::GetProductsError(err),
+}
+});
+ self.task = Some(api::get_products(handler));
+ true
+}
+ Msg::GetProductsSuccess(products) => {
+ self.state.products = products;
+ self.state.get_products_loaded = true;
+ true
+}
+ Msg::GetProductsError(error) => {
+ self.state.get_products_error = Some(error);
+ self.state.get_products_loaded = true;
+ true
+}Msg::AddToCart(product_id) => { let product = self .state .products .iter() .find(|p: &&Product| p.id == product_id) .unwrap(); let cart_product = self .state .cart_products .iter_mut() .find(|cp: &&mut CartProduct| cp.product.id == product_id); if let Some(cp) = cart_product { cp.quantity += 1; } else { self.state.cart_products.push(CartProduct { product: product.clone(), quantity: 1, }) } true } } } fn change(&mut self, _: Self::Properties) -> ShouldRender { true } fn view(&self) -> Html { let products: Vec<Html> = self .state .products .iter() .map(|product: &Product| { let product_id = product.id; html! { <div> <img src={&product.image}/> <div>{&product.name}</div> <div>{"$"}{&product.price}</div> <button onclick=self.link.callback(move |_| Msg::AddToCart(product_id))>{"Add To Cart"}</button> </div> } }) .collect(); Let cart_value = self. State. Cart_products. Iter (). A fold (0.0, | acc, cp | acc + (cp) quantity as f64 * cp) product. The price));+ if ! self.state.get_products_loaded {
+ html! {
+ 
      
{"Loading ..." }
+} + } else if let Some(_) = self.state.get_products_error { + html! { +
+ {"Error loading products! :("} + +} + } else {html! { <div> <span>{format! ("Cart Value: {:.2}", cart_value)}</span> <span>{products}</span> </div> }+}}}Copy the code

There are many changes, but you should be able to understand most of them.

  • We’ve already put togethercreateIs replaced with an empty array. We have toupdatesendMsg::GetProducts, it will callapiIn the moduleget_productsMethods. The returnedFetchTaskWill be stored totaskIn the.
  • When the network request is successful,Msg::GetProductsSuccessThe message and (corresponding) product list are called, orMsg::GetProductsErrorAnd (corresponding) errors are called.
  • The two messages are set separately in the stateproducts å’Œ get_products_errorField. After the request is completed, they also place the status inget_products_loadedAssign the value to true.
  • inviewMethod, we use conditional rendering to render the loading view, error view, or production view based on the component’s state.

Break it down into reusable components

Let’s extract the “Product Card” component into its own module so we can use it in other pages.

// src/components/product_card.rs
use crate::types::Product;
use yew::prelude::*;

pub struct ProductCard {
    props: Props,
}

#[derive(Properties, Clone)]
pub struct Props {
    pub product: Product,
    pub on_add_to_cart: Callback<()>,
}

impl Component for ProductCard {
    type Message = ();
    type Properties = Props;

    fn create(props: Self::Properties, _link: ComponentLink<Self- > >)Self {
        Self { props }
    }

    fn update(&mut self, _msg: Self::Message) -> ShouldRender {
        true
    }

    fn change(&mut self, _props: Self::Properties) -> ShouldRender {
        true
    }

    fn view(&self) -> Html {
        let onclick = self.props.on_add_to_cart.reform(|_| ());

        html! {
          <div>
            <img src={&self.props.product.image}/>
            <div>{&self.props.product.name}</div>
            <div>{"$"} {&self.props.product.price}</div>
            <button onclick=onclick>{"Add To Cart"}</button>
          </div>
        }
    }
}
Copy the code
// src/components/mod.rs
mod product_card;

pub use product_card::ProductCard;
Copy the code
  // src/lib.rs
  mod api;
+ mod components;
  mod pages;
  mod types;

  // No changes
Copy the code
  // src/pages/home.rs

  use crate::api;
+ use crate::components::ProductCard;
  use crate::types::{CartProduct, Product};
  use anyhow::Error;
  use yew::format::Json;
  use yew::prelude::*;
  use yew::services::fetch::FetchTask;

  // No changes

  impl Component for Home {
      // No changes

      fn view(&self) -> Html {
          let products: Vec<Html> = self
              .state
              .products
              .iter()
              .map(|product: &Product| {
                  let product_id = product.id;
                  html! {
- 
      
- -
{&product.name}
-
{"$"}{&product.price}
- - + } }) .collect(); // No changes } } Copy the code

With the exception of Properties, Callback, and Reform, it’s pretty straightforward.

  • PropertiesAs mentioned at the beginning of this article, “Properties” or “Props” is an input to a component. If you think of components as functions, Props are arguments to the function.
  • forProductCardComponent, we willProductThe structure and oneon_add_to_cartThe callback is passed to him. This component does not store any state, so when the user clicks the Add to Cart button, the component calls its parent to update itcart_productsState. This callback starts withCallback<T>Type rendering, and to call it from a child component, we use it on the callbackemit 或 reformMethods.

style

Since we haven’t added any styles yet, the UI looks rudimentary.

We can use class attributes or inline styles in Yew. Let’s add some styles to make the UI look nice.

We create a new CSS file static/styles. CSS, add it to static/index.html and then we can start using classes in our component.

  // src/pages/home.rs

  html! {
    <div>
- {format! ("Cart Value: {:.2}", cart_value)}
- {products}
+ 
      
+ 
      
+ 
      
+ 
+ 
      
{products}
</div> } Copy the code
  // src/components/product_card.rs

  html! {
- 
      
- -
{&self.props.product.name}
-
{"$"}{&self.props.product.price}
- - +
+ +
{&self.props.product.name}
+
{"$"}{&self.props.product.price}
+ + } Copy the code

After adding styles and more products, the UI looks like this:

CSS changes are beyond the scope of this article; refer to the GitHub repository.

routing

In the server-side rendered pages (Jinja, ERB, JSP, and so on), each user sees a page mapped to a different template file. For example, when the user navigates to /login, the page is rendered on the server using login.html; When the user goes to the “/ Settings” page, it is rendered using “settings.html”. Unique urls for different UI pages also help with bookmarking and sharing.

Since there is only one HTML Page in SPA (” Single Page “in SPA,” Single Page “), we should be able to replicate the above behavior. This is done using a Router. Routing maps different URL paths (with query parameters, fragments, and so on) to different page components and helps navigate between multiple pages without reloading.

For our application, we will use the following mapping:

/            => HomePage
/product/:id => ProductDetailPage
Copy the code

Let’s install yew-router:

[package] name = "rustmart" version = "0.1.0" authors = ["sheshbabu <[email protected]>"] edition = "2018" Crate -type = ["cdylib", "rlib"] [dependencies] Yew = "0.17"+ yew - the router = "0.14.0"Wasm-bindgen = "0.2" log = "0.4.6" WASM-Logger = "0.2.0" Anyhow = "1.0.32" serde = {version = "1.0", features = ["derive"] }Copy the code

Let’s add routes to a dedicated file to see all available routes at a glance:

// src/route.rs
use yew_router::prelude::*;

#[derive(Switch, Debug, Clone)]
pub enum Route {
    #[to = "/"]
    HomePage,
}

Copy the code

Currently there is only one route. We’ll add more later.

We create a new file SRC /app.rs to replace HomePage as the root component:

use yew::prelude::*;
use yew_router::prelude::*;

use crate::pages::Home;
use crate::route::Route;

pub struct App {}

impl Component for App {
    type Message = ();
    type Properties = ();

    fn create(_: Self::Properties, _link: ComponentLink<Self- > >)Self {
        Self{}}fn update(&mut self, _msg: Self::Message) -> ShouldRender {
        true
    }

    fn change(&mut self, _: Self::Properties) -> ShouldRender {
        false
    }

    fn view(&self) -> Html {
        let render = Router::render(|switch: Route| match switch {
            Route::HomePage => html! {<Home/>},
        });

        html! {
            <Router<Route, ()> render=render/>
        }
    }
}
Copy the code

Let’s change this in lib.rs:

  mod api;
+ mod app;
  mod components;
  mod pages;
+ mod route;
  mod types;

- use pages::Home;
  use wasm_bindgen::prelude::*;
  use yew::prelude::*;

  #[wasm_bindgen(start)]
  pub fn run_app() {
      wasm_logger::init(wasm_logger::Config::default());
- App::
      
       ::new().mount_to_body();
      
+ App::
      <:app>
       ::new().mount_to_body();
      
  }
Copy the code

So far, this is our component hierarchy:

Product Details page

Now that we have a route, let’s use it to navigate from one page to another. Since this is a SPA, we should avoid reloading the page during navigation.

We add a route under /product/: ID for the product details page. When the user clicks on a ProductCard, it goes to its details page, where the ID of the route is passed in as a Prop.

  // src/route.rs
  use yew_router::prelude::*;

  #[derive(Switch, Debug, Clone)]
  pub enum Route {
+ #[to = "/product/{id}"]
+ ProductDetail(i32),
      #[to = "/"]
      HomePage,
  }
Copy the code

Note that the order of the routes above determines which pages will be rendered first. For example, the url /product/2 matches both /product/{id} and /, but we put /product/{id} first, so the ProductDetail page will be rendered instead of Home.

Add this route to app.rs:

  use yew::prelude::*;
  use yew_router::prelude::*;

- use crate::pages::{Home};
+ use crate::pages::{Home, ProductDetail};
  use crate::route::Route;

  pub struct App {}

  impl Component for App {
      // No changes

      fn view(&self) -> Html {
          let render = Router::render(|switch: Route| match switch {
+ Route::ProductDetail(id) => html! {
      
       },
      
              Route::HomePage => html! {<Home/>},
          });

          html! {
              <Router<Route, ()> render=render/>
          }
      }
  }
Copy the code

Let’s update ProductCard so that clicking on a product image, name, or price will navigate to this new page:

  // src/components/product_card.rs
+ use crate::route::Route;
  use crate::types::Product;
  use yew::prelude::*;
+ use yew_router::components::RouterAnchor;

  // No changes

  impl Component for ProductCard {
      // No changes

      fn view(&self) -> Html {
+ type Anchor = RouterAnchor
      
       ;
      
          let onclick = self.props.on_add_to_cart.reform(|_| ());

          html! {
              <div class="product_card_container">
+ 
      
                      <img class="product_card_image" src={&self.props.product.image}/>
                      <div class="product_card_name">{&self.props.product.name}</div>
                      <div class="product_card_price">{"$"}{&self.props.product.price}</div>
+ 
                  <button class="product_atc_button" onclick=onclick>{"Add To Cart"}</button>
              </div>
          }
      }
  }
Copy the code

Notice how we use classes instead of class for Anchor.

Json, static/products/2.json, static/products/2.

{
  "id": 1."name": "Apple"."description": "An apple a day keeps the doctor away"."image": "/products/apple.png"."price": 3.65
}
Copy the code

Let’s update the API. Rs module with a new route:

  use crate::types::Product;
  use anyhow::Error;
  use yew::callback::Callback;
  use yew::format::{Json, Nothing};
  use yew::services::fetch::{FetchService, FetchTask, Request, Response};

  pub type FetchResponse<T> = Response<Json<Result<T, Error>>>;
  type FetchCallback<T> = Callback<FetchResponse<T>>;

  pub fn get_products(callback: FetchCallback<Vec<Product>>) -> FetchTask {
      let req = Request::get("/products/products.json")
          .body(Nothing)
          .unwrap();

      FetchService::fetch(req, callback).unwrap()
  }

+ pub fn get_product(id: i32, callback: FetchCallback<Product>) -> FetchTask {
+ let req = Request::get(format! ("/products/{}.json", id))
+ .body(Nothing)
+ .unwrap();
+
+ FetchService::fetch(req, callback).unwrap()
+}
Copy the code

Finally, here is the ProductDetail page component:

// src/pages/product_detail.rs
use crate::api;
use crate::types::Product;
use anyhow::Error;
use yew::format::Json;
use yew::prelude::*;
use yew::services::fetch::FetchTask;

struct State {
    product: Option<Product>,
    get_product_error: Option<Error>,
    get_product_loaded: bool,}pub struct ProductDetail {
    props: Props,
    state: State,
    link: ComponentLink<Self>,
    task: Option<FetchTask>,
}

#[derive(Properties, Clone)]
pub struct Props {
    pub id: i32,}pub enum Msg {
    GetProduct,
    GetProductSuccess(Product),
    GetProductError(Error),
}

impl Component for ProductDetail {
    type Message = Msg;
    type Properties = Props;

    fn create(props: Self::Properties, link: ComponentLink<Self- > >)Self {
        link.send_message(Msg::GetProduct);

        Self {
            props,
            state: State {
                product: None,
                get_product_error: None,
                get_product_loaded: false,
            },
            link,
            task: None,}}fn update(&mut self, message: Self::Message) -> ShouldRender {
        match message {
            Msg::GetProduct => {
                let handler = self
                    .link
                    .callback(move |response: api::FetchResponse<Product>| {
                        let (_, Json(data)) = response.into_parts();
                        match data {
                            Ok(product) => Msg::GetProductSuccess(product),
                            Err(err) => Msg::GetProductError(err),
                        }
                    });

                self.task = Some(api::get_product(self.props.id, handler));
                true
            }
            Msg::GetProductSuccess(product) => {
                self.state.product = Some(product);
                self.state.get_product_loaded = true;
                true
            }
            Msg::GetProductError(error) => {
                self.state.get_product_error = Some(error);
                self.state.get_product_loaded = true;
                true}}}fn change(&mut self, _: Self::Properties) -> ShouldRender {
        false
    }

    fn view(&self) -> Html {
        if let Some(ref product) = self.state.product {
            html! {
                <div class="product_detail_container">
                    <img class="product_detail_image" src={&product.image}/>
                    <div class="product_card_name">{&product.name}</div>
                    <div style="margin: 10px 0; line-height: 24px;">{&product.description}</div>
                    <div class="product_card_price"> {"$"}{&product.price}</div>
                    <button class="product_atc_button"> {"Add To Cart"}</button>
                </div>
            }
        } else if !self.state.get_product_loaded {
            html! {
                <div class="loading_spinner_container">
                    <div class="loading_spinner"></div>
                    <div class="loading_spinner_text"> {"Loading ..."}</div>
                </div>
            }
        } else {
            html! {
                <div>
                    <span>{"Error loading product! : ("}</span>
                </div>
            }
        }
    }
}
Copy the code

Very similar to the home page component. Let’s also add this file to the module tree:

  // src/pages/mod.rs
  mod home;
+ mod product_detail;

  pub use home::Home;
+ pub use product_detail::ProductDetail;
Copy the code

(Now) you can see the following results:

Now we can move between multiple pages without reloading the page!

State management

You may have noticed one: inProductDetailPage, clicking the Add to Cart button does not update the cart. This is because of the storage cartcart_productsThe status of the product list inHomePage component:

We can share state between two components in either of the following ways:

  • Ascend the state into a common ancestor
  • Move the state to the global application state

The App component is a common ancestor of ProductDetail and Home. We can move the cart_products state there and pass it as props to ProductDetail and Home.

This works for shallow component hierarchies, but when you have a deep component hierarchy (which is common in larger spas), you will need to pass state through multiple layers of components (which may not use the prop) to reach the target node. This is called “Prop Drilling.”

You can see that Cart_products is now passed from the App to the AddToCart component via ProductDetail and Home, even though they don’t use this state. Imagine the same scenario where there are many layers of components.

This is what global state solves. It goes like this:

Notice how there is a direct link between those components that require that state and the global state.

Unfortunately, Yew doesn’t seem to have a good solution. The recommended solution is to use Agents to broadcast state changes through pubsub (publish subscriptions). This is something I don’t want to do because it can quickly become unmanageable. I hope in the future we’ll see things like React Context, Redux, Mobx, etc.

Let’s solve our problem by elevating state (the level we are in).

ascension

We’ll refactor our code to move the CARt_products state to App and extract Navbar and AtcButton as separate components:

// src/components/navbar.rs
use crate::types::CartProduct;
use yew::prelude::*;

pub struct Navbar {
    props: Props,
}

#[derive(Properties, Clone)]
pub struct Props {
    pub cart_products: Vec<CartProduct>,
}

impl Component for Navbar {
    type Message = ();
    type Properties = Props;

    fn create(props: Self::Properties, _link: ComponentLink<Self- > >)Self {
        Self { props }
    }

    fn update(&mut self, _msg: Self::Message) -> ShouldRender {
        true
    }

    fn change(&mut self, props: Self::Properties) -> ShouldRender {
        self.props = props;
        true
    }

    fn view(&self) -> Html {
        let cart_value = self
            .props
            .cart_products
            .iter()
            .fold(0.0, |acc, cp| acc + (cp.quantity as f64 * cp.product.price));

        html! {
            <div class="navbar">
                <div class="navbar_title"> {"RustMart"}</div>
              <div class="navbar_cart_value"> {format!("${:.2}", cart_value)}</div>
            </div>
        }
    }
}
Copy the code

Notice how we started using the change lifecycle method in the Navbar component. When the props from the parent changes, we need to update the props in that component to make the UI rerender.

// src/components/atc_button.rs
use crate::types::Product;
use yew::prelude::*;

pub struct AtcButton {
    props: Props,
    link: ComponentLink<Self>,}#[derive(Properties, Clone)]
pub struct Props {
    pub product: Product,
    pub on_add_to_cart: Callback<Product>,
}

pub enum Msg {
    AddToCart,
}

impl Component for AtcButton {
    type Message = Msg;
    type Properties = Props;

    fn create(props: Self::Properties, link: ComponentLink<Self- > >)Self {
        Self { props, link }
    }

    fn update(&mut self, msg: Self::Message) -> ShouldRender {
        match msg {
            Msg::AddToCart => self.props.on_add_to_cart.emit(self.props.product.clone()),
        }
        true
    }

    fn change(&mut self, props: Self::Properties) -> ShouldRender {
        self.props = props;
        true
    }

    fn view(&self) -> Html {
        let onclick = self.link.callback(|_| Msg::AddToCart);

        html! {
          <button class="product_atc_button" onclick=onclick>{"Add To Cart"}</button>
        }
    }
}
Copy the code
  // src/components/mod.rs
+ mod atc_button;
+ mod navbar;
  mod product_card;

+ pub use atc_button::AtcButton;
+ pub use navbar::Navbar;
  pub use product_card::ProductCard;
Copy the code

Use the new AtcButton in ProductCard and ProductDetail:

  // src/components/product_card.rs
+ use crate::components::AtcButton;
  use crate::route::Route;
  use crate::types::Product;
  use yew::prelude::*;
  use yew_router::components::RouterAnchor;

  pub struct ProductCard {
      props: Props,
  }

  #[derive(Properties, Clone)]
  pub struct Props {
      pub product: Product,
- pub on_add_to_cart: Callback<()>,
+ pub on_add_to_cart: Callback
      
       ,
      
  }

  impl Component for ProductCard {
      // No changes

      fn view(&self) -> Html {
          type Anchor = RouterAnchor<Route>;
- let onclick = self.props.on_add_to_cart.reform(|_| ());

          html! {
              <div class="product_card_container">
                  <Anchor route=Route::ProductDetail(self.props.product.id) classes="product_card_anchor">
                      <img class="product_card_image" src={&self.props.product.image}/>
                      <div class="product_card_name">{&self.props.product.name}</div>
                      <div class="product_card_price">{"$"}{&self.props.product.price}</div>
                  </Anchor>
- 
+ 
      
              </div>
          }
      }
  }
Copy the code
  // src/pages/product_detail.rs
  use crate::api;
+ use crate::components::AtcButton;
  use crate::types::Product;
  use anyhow::Error;
  use yew::format::Json;
  use yew::prelude::*;
  use yew::services::fetch::FetchTask;

  // No changes

  #[derive(Properties, Clone)]
  pub struct Props {
      pub id: i32,
+ pub on_add_to_cart: Callback
      
       ,
      } impl Component for ProductDetail { // No changes fn view(&self) -> Html { if let Some(ref product) = self.state.product { html! { <div class="product_detail_container"> <img class="product_detail_image" src={&product.image}/> <div class="product_card_name">{&product.name}</div> <div style="margin: 10px 0; line-height: 24px;" >{&product.description}</div> <div class="product_card_price">{"$"}{&product.price}</div>- 
+ 
      
                  </div>
              }
          }

          // No changes
      }
  }
Copy the code

Finally, move the cart_products state from Home to App:

  // src/app.rs
+ use crate::components::Navbar;
+ use crate::types::{CartProduct, Product};
  use yew::prelude::*;
  use yew_router::prelude::*;

  use crate::pages::{Home, ProductDetail};
  use crate::route::Route;

+ struct State {
+ cart_products: Vec
      
       ,
      
+}

- pub struct App {}
+ pub struct App {
+ state: State,
+ link: ComponentLink
      
       ,
      
+}

+ pub enum Msg {
+ AddToCart(Product),
+}

  impl Component for App {
- type Message = ();
+ type Message = Msg;
      type Properties = ();

- fn create(_: Self::Properties, _link: ComponentLink
      
       ) -> Self {
      
+ fn create(_: Self::Properties, link: ComponentLink
      
       ) -> Self {
      
+ let cart_products = vec! [];

- Self {}
+ Self {
+ state: State { cart_products },
+ link,
+}
      }

- fn update(&mut self, _msg: Self::Message) -> ShouldRender {
+ fn update(&mut self, message: Self::Message) -> ShouldRender {
+ match message {
+ Msg::AddToCart(product) => {
+ let cart_product = self
+ .state
+ .cart_products
+ .iter_mut()
+ .find(|cp: &&mut CartProduct| cp.product.id == product.id);

+ if let Some(cp) = cart_product {
+ cp.quantity += 1;
+ } else {
+ self.state.cart_products.push(CartProduct {
+ product: product.clone(),
+ quantity: 1,
+})
+}
+ true
+}
+}
- true
      }

      fn change(&mut self, _: Self::Properties) -> ShouldRender {
          false
      }

      fn view(&self) -> Html {
+ let handle_add_to_cart = self
+ .link
+ .callback(|product: Product| Msg::AddToCart(product));
+ let cart_products = self.state.cart_products.clone();

- let render = Router::render(|switch: Route| match switch {
- Route::ProductDetail(id) => html! {
      
       },
      
- Route::HomePage => html! {
      },
+ let render = Router::render(move |switch: Route| match switch {
+ Route::ProductDetail(id) => {
+ html! {
      }
+}
+ Route::HomePage => {
+ html! {
      
       }
      
+}
          });

          html! {
+ <>
+ 
      
                  <Router<Route, ()> render=render/>
+ }}}Copy the code
  // src/pages/home.rs
  // No changes

  struct State {
      products: Vec<Product>,
- cart_products: Vec
      
       ,
      
      get_products_error: Option<Error>,
      get_products_loaded: bool,
  }

+ #[derive(Properties, Clone)]
+ pub struct Props {
+ pub cart_products: Vec
      
       ,
      
+ pub on_add_to_cart: Callback
      
       ,
      
+}

  pub struct Home {
+ props: Props,
      state: State,
      link: ComponentLink<Self>,
      task: Option<FetchTask>,
  }

  pub enum Msg {
- AddToCart(i32),
      GetProducts,
      GetProductsSuccess(Vec<Product>),
      GetProductsError(Error),
  }

  impl Component for Home {
      type Message = Msg;
- type Properties = ();
+ type Properties = Props;

- fn create(_: Self::Properties, link: ComponentLink
      
       ) -> Self {
      
+ fn create(props: Self::Properties, link: ComponentLink
      
       ) -> Self {
      let products = vec! [];- let cart_products = vec! [];

          link.send_message(Msg::GetProducts);

          Self {
              props,
              state: State {
                  products,
- cart_products,
                  get_products_error: None,
                  get_products_loaded: false,
              },
              link,
              task: None,
          }
      }

      fn update(&mut self, message: Self::Message) -> ShouldRender {
          match message {
              Msg::GetProducts => {
                  self.state.get_products_loaded = false;
                  let handler =
                      self.link
                          .callback(move |response: api::FetchResponse<Vec<Product>>| {
                              let (_, Json(data)) = response.into_parts();
                              match data {
                                  Ok(products) => Msg::GetProductsSuccess(products),
                                  Err(err) => Msg::GetProductsError(err),
                              }
                          });

                  self.task = Some(api::get_products(handler));
                  true
              }
              Msg::GetProductsSuccess(products) => {
                  self.state.products = products;
                  self.state.get_products_loaded = true;
                  true
              }
              Msg::GetProductsError(error) => {
                  self.state.get_products_error = Some(error);
                  self.state.get_products_loaded = true;
                  true
              }
- Msg::AddToCart(product_id) => {
- let product = self
- .state
- .products
- .iter()
- .find(|p: &&Product| p.id == product_id)
- .unwrap();
- let cart_product = self
- .state
- .cart_products
- .iter_mut()
- .find(|cp: &&mut CartProduct| cp.product.id == product_id);
- if let Some(cp) = cart_product {
- cp.quantity += 1;
- } else {
- self.state.cart_products.push(CartProduct {
- product: product.clone(),
- quantity: 1,
-})
-}
- true
-}}}- fn change(&mut self, _: Self::Properties) -> ShouldRender {
+ fn change(&mut self, props: Self::Properties) -> ShouldRender {
+ self.props = props;
          true
      }

      fn view(&self) -> Html {
          let products: Vec<Html> = self
              .state
              .products
              .iter()
              .map(|product: &Product| {
- let product_id = product.id;
                  html! {
- 
      
+ 
      
                  }
              })
              .collect();

- let cart_value = self
- .state
- .cart_products
- .iter()
-            .fold(0.0, |acc, cp| acc + (cp.quantity as f64 * cp.product.price));if ! self.state.get_products_loaded { // No changes } else if let Some(_) = self.state.get_products_error { // No changes } else { html! {- 
      
- - - - <div class="product_card_list">{products}</div> - }}}}Copy the code

Now we can finally add to the cart on the ProductDetail page and we can see the navigation bar on all pages

We have successfully built a SPA with Rust!

I’ve hosted the demo here and the code is in this GitHub repository. If you have questions or suggestions, please contact me at sheshbabu [at] gmail.com.

conclusion

Yew Community has been designing things like HTML! , Component, etc., so people like me who know React can get to work right away. It definitely has some rough spots like FetchTask, lack of predictable state management, and low documentation, but once these issues are resolved, it has the potential to be a good alternative to React, Vue, and so on.

Thanks for reading! Please follow me on Twitter to read more articles like this 🙂

If you find any mistakes in your translation or other areas that need to be improved, you are welcome to the Nuggets Translation Program to revise and PR your translation, and you can also get the corresponding reward points. The permanent link to this article at the beginning of this article is the MarkDown link to this article on GitHub.


The Nuggets Translation Project is a community that translates quality Internet technical articles from English sharing articles on nuggets. The content covers Android, iOS, front-end, back-end, blockchain, products, design, artificial intelligence and other fields. If you want to see more high-quality translation, please continue to pay attention to the Translation plan of Digging Gold, the official Weibo, Zhihu column.