Displaying a list

Now we are going to connect our two components, the dioxus frontend and the axum backend! We are going to display a list in the frontend, which is served by our backend.

Shared Models

As we now connect our front- and backend, it makes sense to share the data models (otherwise we have to define and sync those in both crates). On the backend we need to serialize to json. On the frontend we need to deserialize from json.

So go ahead top-level and create a new crate. As this is a library, we create it with the --lib tag.

cargo new --lib model

In this crate we will put all of our models, that are shared between our front- and backend. They will correspond to the json data you can see in your network tab. Go into this new model crate and add serde to this crate

cargo add serde -F derive

Then go ahead and add our first shared model.

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
pub struct ShoppingListItem {
    pub title: String,
    pub posted_by: String,
    pub uuid: String,
}
}

Great - now we can use this structure on both sides! Do not forget to add this crate to our workspace (top level Cargo.toml).

[workspace]
resolver = "2"
members = [
    "frontend",
    "backend",
    "model"
]

Makefile

With more and more moving parts, let's first introduce a Makefile.toml which will make our life much easier. With cargo-make. We can execute our three commands concurrently with one command, which is:

  • serving our backend
  • serving our frontend
  • telling tailwind to watch files, and to recompile the css if needed.

Also we use cargo-watch, that will watch specific files and recompile our code, once we save one of the watched files.

Go ahead an create Makefile.toml at the top level.

[tasks.backend-dev]
install_crate = "cargo-watch"
command = "cargo"
args = ["watch", "-w", "backend", "-w", "model", "-x", "run --bin backend"]

[tasks.frontend-dev]
install_crate = "dioxus-cli"
command = "dx"
args = ["serve", "--bin", "frontend", "--hot-reload"]

[tasks.tailwind-dev]
command = "npx"
args = ["tailwindcss", "-i",  "./frontend/input.css", "-o", "./frontend/public/tailwind.css", "--watch"]

[tasks.dev]
run_task = { name = ["backend-dev", "tailwind-dev", "frontend-dev"], parallel = true}

This file describes all the processes it will execute, when you type in cargo make. But for this to work go ahead and install cargo-watch cargo-make

cargo install cargo-watch
cargo install cargo-make

Great! Now, if you execute

cargo make --no-workspace dev

Every single command we used before is now executed within one command. With hot-reload!

Backend

Before we can fetch a list in the frontend, let's offer a list in backend first. For this, go to your backend crate and use the struct, we defined in our model crate.

Therefore, add the model crate as dependency to the backend Cargo.toml.

model = { path = "../model" }

Then add a use statement in our current main.rs:

#![allow(unused)]
fn main() {
use model::ShoppingListItem;
}

Then we need a controller, that sends those items:

#![allow(unused)]
fn main() {
async fn get_items() -> impl IntoResponse {
    let items = vec!["milk", "eggs", "potatoes", "dogfood"];

    let uuid: &str = "a28e2805-196b-4cdb-ba5c-a1ac18ea264a";
    let result: Vec<ShoppingListItem> = items
        .iter()
        .map(|item| ShoppingListItem {
            title: item.to_string(),
            posted_by: "Roland".to_string(),
            uuid: uuid.to_string(),
        })
        .collect();

    Json(result)
}
}

For now we ignore the uuid generation. Every item has the same - but we'll need it for later. Same for Roland ;o.

Now add this to our router

#![allow(unused)]
fn main() {
      .route("/items", get(get_items));
}

and run cargo make --now-workspace dev. Opening a webbrowser with localhost:3001/items should give you those items.

Frontend

Now that you have a backend, which can serve a list of items. Let's build a rudimentary frontend, that can display the list. Hop to your frontend crate.

Fetching items

First let's add some logic to fetch data from the backend. We can do this by using the reqwest crate (a https client library). We also need to add our model crate.

cargo add reqwest -F json
cargo add model --path ../model

Let's add a small function, that is fetching items.

#![allow(unused)]
fn main() {
use model::ShoppingListItem;

async fn get_items() -> Result<Vec<ShoppingListItem>, reqwest::Error> {
    let url = "http://localhost:3001/items";
    let list = reqwest::get(url)
        .await?
        .json::<Vec<ShoppingListItem>>()
        .await;

    list
}
}

Quite some things are happening here. Let us look at the lines. We fetch something from the backend with reqwest::get(url).await, that returns a Result, containing the payload of that request, or an error. The ?-operator unwraps this Result - continuing the chain, if the Result was ok. If it was an error, the ?-operator stops immediately and returns the error. It also converts the error to the error type from the function, that is reqwest:Error.

Then the successfully loaded payload is deserialized with .json::<Vec<ShoppingListItem>>. That also can fail, so this returns a Result. But instead of checking this Result with another ?-operator, we just return this Result from the function.

Displaying items

Dioxus is a component based web framework - it is comparable to e.g. React, where you nest components into one another. Also, if one of the properties in a component changes, the components will be re-rendered.

So let's create a component, that displays a single item - and then embed this item component into a list, displaying all components

#![allow(unused)]
fn main() {
#[component]
fn ShoppingListItemComponent(display_name: String, posted_by: String) -> Element {
    rsx! {
        div {
            class: "flex items-center space-x-2",
            p {
                class: "grow text-2xl",
                "{display_name}"
            }
            span {
                "posted by {posted_by}"
            }
        }
    }
}
}

Anytime one of the properties, either display_name or posted_by is changing, the component is going to be re-rendered.

Now we are going to use the above component to display a list. In Another component

#![allow(unused)]
fn main() {
#[component]
fn ShoppingList() -> Element {
    let items_request = use_resource(move || async move { get_items().await });

    match &*items_request.read_unchecked() {
        Some(Ok(list)) => rsx! {
            div { class: "grid place-items-center min-h-500",
                ul {
                    class: "menu bg-base-200 w-200 rounded-box gap-1",
                    for i in list {
                        li {
                            key: "{i.uuid}",
                            ShoppingListItemComponent{
                                display_name: i.title.clone(),
                                posted_by: i.posted_by.clone()
                            },
                        }
                    }
                }
            }
        },
        Some(Err(err)) => {
            rsx! {
                p {
                    "Error: {err}"
                }
            }
        }
        None => {
            rsx! {
                p {
                    "Loading items..."
                }
            }
        }
    }
}
}

The use_resource is one of Dioxus hooks. Hooks help you to have stateful functionality in your components. use_resource especially lets you run async closures and return a result. In this case we match the result of the close. On the first render, there will be no data available. So matching will result in a None. The component will be re-rendered, once the future is finished and the result will be Some(...)thing.

If you now add the ShoppingList component to our App and execute our cargo make command (if it's not running already) you will see our list!

#![allow(unused)]
fn main() {
pub fn App() -> Element {
    let rust_basel = "Rust Basel";
    rsx! {
        h1{
            "Welcome to {rust_basel}"
        }
        button{
            class: "btn",
            "My stylish button"
        }
        ShoppingList{}
    }
}
}

Great! Now you got the basics! Let's head to our backend to create a rudimentary database - so we can later also add or remove items to the list.

CORS

It may happen, that your browser denies fetching data from your backend because of Cross-Origin Resource Sharing (CORS). For security reasons browser restrict cross-origin HTTP requests. Your server has to explicitly tell the browser, which sources, or origins, are allowed to fetch the server's resources. For more info have a read at the MDN Web docs.

In order to tell our server that it is ok to fetch data from it, we can add a cors layer to our Router. That layer is another middleware. So the cors logic is applied to all routes we already wrote, and the server sends appropriate CORS headers with each response.

Therefore add the tower-http crate.

cargo add tower-http -F cors

Afterwards you can add a layer around your existing routes, like so:

#![allow(unused)]
fn main() {
use tower_http::cors::CorsLayer;
}

And in your main function:

#![allow(unused)]
fn main() {
let app = Router::new()
    .route("/", get(hello_world))
    .route("/:name", get(hello_name))
    .route("/your-route", post(workshop_echo))
    .route("/items", get(get_items))
    .layer(CorsLayer::permissive());
}

Note: Do not use permissive in production - but choose a cors policy, that fits your setting in production.

Dioxus.toml

Inside our frontend crate, we now also want something called a Dioxus.toml. It's a configuration file for dioxus, that is read by the dioxus-cli. Go ahead and create a Dioxus.toml with sane defaults.

Dioxus.toml

[application]

# App (Project) Name
name = "workshop-2"

# Dioxus App Default Platform
# desktop, web, mobile, ssr
default_platform = "web"

# `build` & `serve` dist path
out_dir = "dist"

# resource (public) file folder
asset_dir = "public"

[web.app]

# HTML title tag content
title = "Shopping list"

[web.watcher]

# when watcher trigger, regenerate the `index.html`
reload_html = true

# which files or dirs will be watcher monitoring
watch_path = ["src", "public"]

# uncomment line below if using Router
# index_on_404 = true

# include `assets` in web platform
[web.resource]

# CSS style file
style = []

# Javascript code file
script = []

[web.resource.dev]

# serve: [dev-server] only

# CSS style file
style = []

# Javascript code file
script = []

Here you can define other properties, like a custom css file or custom js scripts to integrate. But we do not need this for now. Only that you have seen it :)

CSS hot-reloading

You might have noticed so far that you had to refresh or recompile for tailwind classes to refresh on the screen. For proper hot reloading, you need manganis. This is a crate provided by Dioxus to make a e.g. a style file available for css hot-reloading.

cargo add manganis

Add this line below in the main.rs of your frontend crate.

const _STYLE: &str = manganis::mg!(file("public/tailwind.css"));

(so far we have worked with implicit defaults, such as the style property). The style property has to be set to [] for manganis to work in our Dioxus.toml.

It is a good indicator of how mature a frontend framework is in 2024, if it provides reliable tailwind hot reloading support out of the box or with a plugin or two. Dioxus is surprisingly far, but the reloading is not fully reliable yet.