Intro

Today we will build a shopping list, that you can edit and share with your friends! So you never have to write one on your own again.

The fullstack workshop will be a guided workshop, where we will implement the things together. If you do not want to copy all this boilerplate code, you can have a look at the finished repo https://github.com/rust-basel/axum-dioxus-shopping-list.

If you need specifically the solution for a chapter, you can have a look at the solutions repository. It is structured after the chapters in this book.

If you see any issues - or errors in this book, or the solutions we are very happy for pull requests to fix these.

Architecture

We strive for a simple architecture with a frontend, backend and a database. For simplicity reasons, we implement a simple in memory database. This means that we will lose our data as soon as we stop the server process, but for the workshop app this is good enough.

Simple Architecure for our workshop project

Goal

At the end of the workshop you are going to have a fully working small web application, in which you can create and share shopping lists. Those lists can also be edited from several people at the same time.

The app is styled with Tailwind, along with a component library called DaisyUi.

The final result can look like this:

Loading or Creating a list

Page, where you can load a shopping list

Editing list

Page, where you can load a shopping list

Setup

In order to have our fullstack project, we need some setup first:

Prerequistes

For the setup we need as prerequisites:

Project structure

We will have everything in the same repository. So the backend and the frontend are easier to keep in sync for code updates.

First start with a directory, let's call it fullstack-workshop and hop into this directory (and init git).

mkdir fullstack-workshop
cd fullstack-workshop
git init

After this one, create two new cargo projects within this directory.

cargo new frontend
cargo new backend

After this one, let's create a workspace! Add a top level Cargo.toml (in the fullstack-workshop directory)

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

Perfect! Now the core setup stands. If you run cargo build both, the frontend and your backend crate should be build.

Install Dioxus CLI

Dioxus comes with a CLI that wraps around cargo, for providing features such as hot reloading and reading configuration from a Dioxus.toml file, if one is present.

We can just install it system-wide with cargo:

cargo install dioxus-cli

If you had problems with the setup, you can just copy the code from here

Dioxus Hello World

Prerequisites

If you have not done so already, install the dioxus cli:

cargo install dioxus-cli

This is a wrapper around cargo and installed globally, since it can also be used to init new projects.

First Hello World

Our dioxus part will be the frontend directory. So go ahead an go into the frontend dir

cd frontend

Now add the needed dependencies to your Cargo.toml

cargo add dioxus --features web

and add the following to your main.rs in src/.

use dioxus::prelude::*;

fn main() {
    launch(App);
}

#[allow(non_snake_case)]
pub fn App() -> Element {
    rsx! {"Hello World"}
}

Components in Dioxus are plain functions starting with a capital letter (PascalCase). The rsx! macro is Dioxus own Domain Specific Language (dsl), which works similar to jsx in React for example.

Let's run our frontend! For this we now need the prior installed dioxus-cli.

dx serve --hot-reload

The --hot-reload flag will recompile your code, when you save your files, that are currently being served. With default settings, you can now watch your hello world application at localhost:8080.

Congrats! Your (maybe) first webpage written in Rust!

If you want, you can change the content in your hello world. For example using a variable?

#![allow(unused)]
fn main() {
pub fn App() -> Element {
    let rust_basel = "Rust Basel";
    rsx! {"Welcome to {rust_basel}"}
}
}

It's all about style - let's add tailwind and daisyUi

Great. Now that our web hello world is running, let's add some styling to it. Of course no one likes raw CSS, which is why we are going to add Tailwindcss, which has some sane defaults and add a tailwind component library to it. It's called DaisyUi. You define the style of your DOM components directly as classes in the rsx macro. No need for an extra css file, you have to maintain. Tailwind will watch the style classes, you define within your markdown and will automatically generate the correct css for you.

Daisyui is a tailwind component library. As tailwind itself is quite mighty, DaisyUI enhances tailwind and make styling easy having ready to use components like buttons, navbars, etc.

Installing dependencies

First we go ahead and install our dependencies. We want all configuration files top-level. So go back, where your top-level Cargo.toml is and initialize npm there. Then install tailwindcss for development and initialize tailwind.

cd ..
npm init

Just say yes to all defaults. This should give a package.json, which looks like this:

package.json:

{
  "name": "fullstack-workshop",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}

Then go ahead and install tailwindcss as development library and initialize it.

npm install -D tailwindcss
npx tailwindcss init

As last, go ahead and install daisyUi.

npm install -D daisyui@latest

Sorry for installing so many dependencies. And welcome to the npm world! :) However, these will only be development dependencies and will run on your machine only, never on the server or client. Only the CSS resulting from build is served.

Changing the configuration files

Now that we have all dependencies, we need to adapt the configuration to let tailwind know where to look for classes.

Changes to tailwind.config.js so it will watch the rsx classes in our .rs files:

module.exports = {
  mode: "all",
  content: [
    // include all rust, html and css files in the src directory
    "./frontend/src/**/*.{rs,html,css}",
    // include all html files in the output (dist) directory
    "./frontend/dist/**/*.html",
  ],
  theme: {
    extend: {},
  },
  plugins: [require("daisyui")],
  daisyui: {
    themes: [
      "cupcake",
    ],
  },
}

At very last, add a input.css to the frontend crate. This is the target file for tailwind to generate the css for you, and this is the only stylesheet file we will serve to the browser.

first cd into the frontend crate

cd frontend

then add a input.css with the following contents:

@tailwind base;
@tailwind components;
@tailwind utilities;

that's it. now let's test our new styles.

let's add a stylish button!

go to your main.rs in the frontend crate, and add a button

#![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"
        }
    }
}
}

The btn class is defined in the daisy ui components https://daisyui.com/components/button/#button. Have a look, if you want to try other classes.

To now let tailwind create the fitting css files, run the tailwind watcher in another terminal. It watches, if any of your watched files (defined in the top-level tailwind.config.js) changes and re-generated the css file.

(Note: run from top-level)

npx tailwindcss -i ./frontend/input.css -o ./frontend/public/tailwind.css --watch

Tailwind will regenerate a css file into your frontend/public/ directory, where dioxus will fetch the style from (by default).

Now, let's finally have a look at our stylish first website!

(Note: run from frontend dir)

dx serve --hot-reload

If everything worked our, you should have a page that looks like this:

Stylish website

You still might need some hard refreshes or server restarts with CTRL + C and dx serve --hot-reload from time to time, but we will refine the hot reloading setup later on. All the commands, you have to run manually now will be put in a nice package that spins everything up with one command.

Dioxus Alternatives

Dioxus is not the only framework of its kind. There is leptos, which is a close competitor which seems to have a narrower focus and seemingly more production use. And there is yew, which has been around for years, and is without a doubt production-ready and battle-tested. Compared with the newer generation, you have to fight around with lifetimes withing your dependency tree a lot. Dioxus is able to circumevent this with clever types, and deferring a few lifetime checks to runtime. This leads to a developer experience comparable to react (except for the tools, of course).

Axum Hello World

Let's start building our HTTP backend!

Dependencies

Go into your backend crate and install the following dependencies:

cargo add axum
cargo add tokio -F full
cargo add serde -F derive
cargo add tower-http -F cors

Axum will be our webservice framework of choice. As it's very extensible with tower-http, where you can easily create middlewares.

Serde is Rusts de facto standard serialization library, so we can easily receive and send json payloads and map them to Rust models.

Tokio is the most popular async runtime (and required by axum, which runs within tokio)

Add a small webservice

Head to your main.rs and add the following minimal executable:

use axum::{extract::Path, response::IntoResponse, routing::get, Router};

async fn hello_world() -> impl IntoResponse {
    "Hello World"
}

async fn hello_name(Path(name): Path<String>) -> impl IntoResponse {
    format!("Hello {name}")
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/", get(hello_world))
        .route("/:name", get(hello_name));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3001").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

The Router lets you define the endpoints of your webservice. You can attach different routes to it, as well as other middlewares. Middlewares are logical units, that are shared across different routes. One example would be an Auth middleware.

The most basic function of axum with a router is mapping requests to handlers. A handler is anything that returns a value that implements IntoResponse, and the most trivial example of this is a String, as in the line format!("Hello {name}").

After adding those few lines run.

cargo run

And wait for your compiler to build and run the app. Your webservice is now running on localhost:3001. So if you go to your browser, your should see a Hello World. If you put localhost:3001/your-name into your browser, then the path parameter your-name is extracted by axum, as you see in the Path(name): Path<String> argument.

Responding with and receiving json payload

If you want to create a resource server side, you usually send a http POST to your server with a Json payload. Serialization is hard? Not with Rust - as we have the Serde crate.

Add a rust struct annotated with serde's Serialize and Deserialize create.

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

#[derive(Serialize, Deserialize)]
struct Workshop {
    attendees_count: i32,
    people_like_it: bool,
}
}

Now this struct Workshop is automatically de- and serializable. The Procedural Macro implements this for us!

If we now want to receive and respond this with Json, we have to add a fitting handler (function) to it.

Add this to your main.rs:

#![allow(unused)]
fn main() {
async fn workshop_echo(Json(workshop): Json<Workshop>) -> impl IntoResponse {
    Json(workshop)
}
}

This will just echo the json. But you get the idea. We also need to add this to our Router. Add it as a post (you could also use get, but idiomatically json payloads are send with either patch, put or post).

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

You can test your json endpoint with the following curl call, to check whether you added it correctly ;).

curl -i \
    -H "Accept: application/json" \
    -H "Content-Type: application/json" \
    -X POST -d '{"attendees_count": 20, "people_like_it": true}' \
    http://localhost:3001/your-route

Whooray! You got the basic building block ready. The next steps will take care fo letting our frontend backend talk to each other. Let's display a list of items from our backend in the frontend in the next chapter.

Axum alternatives

There are many other Rust server frameworks. Axum is still rather new, and seemingly the 'bright star' in this universe. It is extremely fast, because of its simplicity and direct coupling to tokio. Documentation and adaptation in production are still lagging somewhat, but with the current popularity, these things are bound to catch up too. The most popular framework probably still is actix-web, which is also rather lightweight and revolves around the actor pattern. The most full-fledged framework would berocket, with very robust features and tools. While axum is more like expressjs, rocket is more comparable to Ruby on Rails.

Speaking about rails, there is also loco, a framework layer built on top of axum, with the goal of being super small and simple, perfect for one-(wo)man teams.

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.

Creating a database

For this workshop we will create a small in memory database for simplicity reasons. Basically a tiny wrapper around a HashMap with items. For a real adapter, please have a look at sqlx or an Object Relational Mapper (ORM) like SeaOrm or Diesel. The cool thing about axum is, it let's you inject our database like the ones referenced. So if you decide later on to have production ready database implementation - you use it the same way within axum.

But what exactly should our database be capable of?

  • Get/Read an item
  • Get all items
  • Create an item
  • (Update an existing item)
  • Delete an item

This translates directly to Create-Read-Update-Delete (CRUD). Also, for simplicity, we do not use update here. We only delete, create or get items. Deleting is therefore the same as checking of an item.

Create a database module

Let's create an own module in our backend crate for this. Add a new file called database.rs next to the main.rs. To make the backend aware of the new module, add

#![allow(unused)]
fn main() {
mod database;
}

to the top of your main.rs.

Create the database

Inside the new database.rs module create a new struct, called InMemoryDatabase, which wraps a simple HashMap

#![allow(unused)]
fn main() {
use std::collections::HashMap;

pub struct InMemoryDatabase {
    inner: HashMap<String, ShoppingItem>,
}
}

Let's also add some logic to retrieve, add and delete items

#![allow(unused)]
fn main() {
impl InMemoryDatabase {
    fn get_item(&self, uuid: &str) -> Option<&ShoppingItem> {
        self.inner.get(uuid)
    }

    fn insert_item(&mut self, uuid: &str, item: ShoppingItem) {
        self.inner.insert(uuid.to_string(), item);
    }

    fn delete_item(&mut self, uuid: &str) {
        self.inner.remove(uuid);
    }
}
}

Let's also add some fixture items, when creating a default database

#![allow(unused)]
fn main() {
impl Default for InMemoryDatabase {
    fn default() -> Self {
        let inner: HashMap<String, ShoppingItem> = [
            (
                "b8906da9-0c06-45a7-b117-357b784a8612".to_string(),
                ShoppingItem {
                    title: "Salt".to_string(),
                    creator: "Yasin".to_string(),
                },
            ),
            (
                "ac18131a-c7b8-4bdc-95b5-e1fb6cad4576".to_string(),
                ShoppingItem {
                    title: "Milk".to_string(),
                    creator: "Tim".to_string(),
                },
            ),
        ]
        .into_iter()
        .collect();

        Self { inner }
    }
}
}

Adding the database to our axum backend

Adding this database is fairly simple with axums Router builder. Go inside the backends main function, init a default database and use it inside the router.

Before we can use our database, we have to make sure that there is no data race. Or - the compiler checks that for us.

Make a type alias, where we wrap our database in an Arc<RwLock>. An Arc is an atomic and reference counted smart pointer in Rust. The RwLock makes sure, that reads and writes are mutually exclusive. There is only one writer holding a lock. But several readers can hold onto the same lock.

#![allow(unused)]
fn main() {
use std::sync::{Arc, RwLock};
use database::InMemoryDatabase;

type Database = Arc<RwLock<InMemoryDatabase>>;
}

Afterwards you can just initialize this database as default - and inject it with axums with_state(...) router method.

#[tokio::main]
async fn main() {
    let db = Database::default();
    let app = Router::new()
        .route("/", get(hello_world))
        .route("/:name", get(hello_name))
        .route("/your-route", post(workshop_echo))
        .route("/items", get(get_items))
        .with_state(db);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3001").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

This allows to access the state (db) from each handler function by using it as function parameters.

In the next chapter, we will write controllers functions for update, read and delete endpoints, where we use the database.

CRUD Operations

Now that we have a "database" usable in our axum backend. Let's write handling functions, which have access to the database and read and modify it. Head to your backend crate and create again a new module. This time it's called controllers.rs. Why named controllers? There is a famous design pattern called model, view, controller. Controllers are usually those parts, which execute your logic. In our case, you've already seen them. These are the functions, we give the Router as callback in a route.

Do not forget to add new module in the main.rs file.

#![allow(unused)]
fn main() {
mod controllers;
}

Cleanup

Let's remove any other controller we currently have - except the get_items controller. This function you can move to the new controllers.rs module - as this will be our first controller we rewrite to use the database instead of the hardcoded list.

Do not forget to make this one public - by prepending a pub. Otherwise it's not visible outside this new module.

#![allow(unused)]
fn main() {
pub async fn get_items() -> impl IntoResponse {...}
}

Afterwards your main.rs should look like this:

mod controllers;
mod database;

use std::sync::{Arc, RwLock};

use axum::{routing::get, Router};
use controllers::get_items;
use database::InMemoryDatabase;

type Database = Arc<RwLock<InMemoryDatabase>>;

#[tokio::main]
async fn main() {
    let db = Database::default();
    let app = Router::new().route("/items", get(get_items)).with_state(db);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3001").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

As well as your controller.rs:

#![allow(unused)]
fn main() {
use axum::{response::IntoResponse, Json};
use model::ShoppingListItem;

pub 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)
}
}

This should be our starting point.

Reading things

Let's rewrite our get_items controller. Instead of returning a hardcoded list, let's fetch them from the database. Change the signature of your function.

#![allow(unused)]
fn main() {
use axum::extract::State;

pub async fn get_items(State(state): State<Database>) -> impl IntoResponse {...}
}

Now you can access the database, that you've already injected with the with_state(...) method of the Router.

But first, go to your database.rs module and add a new method to your impl InMemoryDatabase to return all items at once.

#![allow(unused)]
fn main() {
  pub fn as_vec(&self) -> Vec<(String, ShoppingItem)> {
      self.inner
          .iter()
          .map(|(uuid, item)| (uuid.clone(), item.clone()))
          .collect()
  }
}

For this, the database item must be Clone, which is Rust lingo for implementing the Clone trait, which we can do automatically for most types by deriving the implementation with a #[derive] attribute. So add this also to the item. Moreover, the struct itself and its members must be public, so one can use it outside the database.rs module.

#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct ShoppingItem {
    pub title: String,
    pub creator: String,
}
}

With those changes, we now can rewrite our get_items function to fetch items from the database.

#![allow(unused)]
fn main() {
pub async fn get_items(State(state): State<Database>) -> impl IntoResponse {
    let items: Vec<ShoppingListItem> = state
        .read()
        .unwrap()
        .as_vec()
        .iter()
        .cloned()
        .map(|(uuid, item)| ShoppingListItem {
            title: item.title,
            posted_by: item.creator,
            uuid,
        })
        .collect();

    Json(items)
}
}

If you now execute all our do-all command cargo make --no-workspace dev, you get the items displayed, which are in the database :). Good Job!

Creating things

In order to add new items to the lists, we have to implement a controller enabling a POST with a json payload. If you remember, you did this one in the beginning.

In order to create a new item, we need two fields. One for the title and one for the creator. Let's create a Data Transfer Object (DTO). As this one is used in both of our crates, the model crate would be a good fit the place it in.

#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize)]
pub struct PostShopItem {
    pub title: String,
    pub posted_by: String,
}
}

Now our controller in the backend can deserialize this object, coming from our frontend. But who creates the unique id? Our backend does this. Therefore, let's add a crate that creates uuids.

cargo add uuid -F serde -F v4

Now let's create our controller:

Add the correct use statements at the top:

#![allow(unused)]
fn main() {
use model::{PostShopItem, ShoppingListItem};
use uuid::Uuid;
use axum::http::StatusCode;
use crate::database::ShoppingItem;
}

Then the controller - using all these:

#![allow(unused)]
fn main() {
pub async fn add_item(
    State(state): State<Database>,
    Json(post_request): Json<PostShopItem>,
) -> impl IntoResponse {
    let item = ShoppingItem {
        title: post_request.title.clone(),
        creator: post_request.posted_by.clone(),
    };
    let uuid = Uuid::new_v4().to_string();

    let Ok(mut db) = state.write() else {
        return (StatusCode::SERVICE_UNAVAILABLE).into_response();
    };

    db.insert_item(&uuid, item);

    (
        StatusCode::OK,
        Json(ShoppingListItem {
            title: post_request.title,
            posted_by: post_request.posted_by,
            uuid,
        }),
    )
        .into_response()
}
}

A lot is happening there. Let's go through it.

The State extractor fetches your database, you injected in you main. We need this to write to the database and insert a new item. Then the Json extractor deserializes your recently created PostShopItem. The cool thing here: If the request is not valid - i.e. the client posts some other json than expected, the controller will automatically return a 400 code for invalid input data it cannot be deserialized into the specified model.

In the body we create the data model we have in our database. For this, we manually map the PostShopItem to a ShoppingItem type. Then we create a universally unique identifier with the Uuid crate (There are different uuid version - we just pick v4).

Then there is rust syntax, the let-else statement, you probably did not see before. It binds the Ok value out of the Result coming from the state.write() statement. That is, if we get the lock to write - then we proceed. Otherwise, the else path, we return early with a service unavailable error code (503).

If we can write, we then insert the new item with the generated uuid and respond with the newly generated item as a json serialized ShoppingListItem.

The last thing we have to do, is now register this controller as POST handler into our axum Router. Go ahead an do that and do not forget the use statement on top:

#![allow(unused)]
fn main() {
use controllers::{add_item, get_items};
}

In your fn main:

#![allow(unused)]
fn main() {
let app = Router::new()
    .route("/items", get(get_items).post(add_item))
    .with_state(db);
}

If you now run everything with our all-do cargo make, then you can already add new items with e.g. curl, or postman.

Here a curl post example

curl -i \
    -H "Accept: application/json" \
    -H "Content-Type: application/json" \
    -X POST -d '{"title": "Pepperoni", "posted_by": "Rustacean"}' \
    http://localhost:3001/items

After adding the item(s), reload the frontend, that is running on localhost:8080 (if you ran the all-do cargo make).

Great - now you know how to add items! Let's fast-forward and add the code to also delete items.

Deleting things

Deletion is similar to creation. We also need writeable access to the database. But we do not have to accept a json payload, but a uuid. The only thing that changes is the extraction of a Path parameter. But you did that already in the axum hello world chapter.

Our deletion controller:

#![allow(unused)]
fn main() {
use axum::extract::Path;

pub async fn delete_item(
    State(state): State<Database>,
    Path(uuid): Path<Uuid>,
) -> impl IntoResponse {
    let Ok(mut db) = state.write() else {
        return StatusCode::SERVICE_UNAVAILABLE;
    };

    db.delete_item(&uuid.to_string());

    StatusCode::OK
}
}

Adding our controller is also straightforward. We only have to add the delete in the Router and add above delete_item controller as callback.

Use statements:

#![allow(unused)]
fn main() {
use axum::routing::delete;
use controllers::delete_item;
}

With the router being expanded with:

#![allow(unused)]
fn main() {
.route("/items/:uuid", delete(delete_item))
}

To delete an item (we do not have that in the UI) you can just use curl again. You only need to have an existing uuid at hand.

curl -X "DELETE" http://localhost:3001/items/<your-uuid>

To get an uuid, just create some items with the curl command from above.

Very good! Now our backend is ready to be consumed by our frontend. Let's head to the next chapter, where we add the needed components.

Adding Items

Now we are going to add items from our frontend!

To achieve this, we will add a form that will post new items to our backend. After sending the new item, the list will be fetched again, so we have the latest list.

Creating a helper function

Let's first create a client function, that sends the post request to our backend. It's similar to the get_items function we have already written. Go to the main.rs of your frontend and create a post_item function.

#![allow(unused)]
fn main() {
async fn post_item(item: PostShopItem) -> Result<ShoppingListItem, reqwest::Error> {
    let response = reqwest::Client::new()
        .post("http://localhost:3001/items")
        .json(&item)
        .send()
        .await?
        .json::<ShoppingListItem>()
        .await?;

    Ok(response)
}
}

The post_item function will be used in our component we will write next.

Adding a form

We need a form with two fields. Namely the item name, as well as the person who wants that item, an author. Let's begin simple. We need a form that has two input fields - and a button to commit those fields. Let's add those.

#![allow(unused)]
fn main() {
#[component]
fn ItemInput() -> Element {
    let mut item = use_signal(|| "".to_string());
    let mut author = use_signal(|| "".to_string());

    // We implement this closure later
    let onsubmit = move |evt: FormEvent| {};

    rsx! {
        div {
            class: "w-300 m-4 mt-16 rounded",
            form { class: "grid grid-cols-3 gap-2",
                onsubmit: onsubmit,
                div {
                    input {
                        value: "{item}",
                        class: "input input-bordered input-primary w-full",
                        placeholder: "next item..",
                        r#type: "text",
                        id: "item_name",
                        name: "item_name",
                        oninput: move |e| item.set(e.data.value().clone())
                    }
                }
                div {
                    input {
                        value: "{author}",
                        class: "input input-bordered input-primary w-full",
                        placeholder: "wanted by..",
                        r#type: "text",
                        id: "author",
                        name: "author",
                        oninput: move |e| author.set(e.data.value().clone())
                    }
                }
                button {
                    class: "btn btn-primary w-full",
                    r#type: "submit",
                    "Commit"
                }
            }
        }
    }
}
}

The content inside the rsx might seem long. But in the end you have three elements. Namely two input fields, as well as a button to commit the two input fields. Currently, we do not push anything to our backend yet. This going to happen in the onsubmit action, which we will implement later. If you ask yourself what the use_signals are doing: They give this component state.

When the component is rendered first, we initialize item and author to empty Strings. Every time we input something into the input fields (oninput actions), the respective symbols are set, when we enter a character. The plan: If we press the button, then we use the current stored strings, build an PostShopItem out them and push this to our backend.

But let's add this component to our existing app first, so you can already examine the UI.

#![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{}
        ItemInput{}
    }
}
}

If you run our cargo make --no-workspace dev, then you can already see the finished form.

OnSubmit Action - posting items

Currently, we do nothing on submit - except changing the state of the two hooks. Let's add a closure, that posts items to our backend. For this we will use spawn. This allows us to run async function inside our components, which are fired once.

#![allow(unused)]
fn main() {
let onsubmit = move |_| {
    spawn({
        async move {
            let item_name = item.read().to_string();
            let author = author.read().to_string();
            let _ = post_item(PostShopItem {
                title: item_name,
                posted_by: author,
            })
            .await;
        }
    });
};
}

As we stored the item as well as the author value inside hooks (our components state), we can just read those values and copy them as strings. For now we just ignore the result of our http call for simplicity.

If you now run everything you are able to create new items. The downside: You do not recognize that you already post items, as the list is not getting updated. But if you open your browser's developer settings (usually f12), you will see in your network tab, that you post items.

But let us fix the list in the next step.

Use resource - dependencies

You can configure the use_resource hook. It is run when the components renders the first time. If you want to re-run the hook, then you have to add a dependency. If this dependency changes, the use_resource is run again. Let's do exactly this.

Let's first introduce something, that signals a change.

#![allow(unused)]
fn main() {
struct ListChanged;
}

In our case a zero-sized object is sufficient.

The idea:

  • When we post an item - and the post request has been successful - then we write to that signal.
  • Our list display subscribes this signal. Because we write to it, anything that depends on it (i.e. our use_resource hook), will be executed again.

In order that both components ItemInput and ShoppingList both have access to that signal, we have to hoist (lifting the state up) the state.

So let's add a use_signal hook on top level and add the signal to both components:

New components signature:

#![allow(unused)]
fn main() {
fn ShoppingList(change_signal: Signal<ListChanged>) -> Element {...}
}
#![allow(unused)]
fn main() {
fn ItemInput(change_signal: Signal<ListChanged>) -> Element {...}
}

and add it to your App function:

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

Let's read the signal on the list fetching side - and write on the input side in the next steps.

Writing the signal

In the ItemInput component, go into your onsubmit callback. Now we can use the Result coming from our post_item http request. Change it and check the result, if it is ok. If it is ok, we write the signal (without writing anything to it).

#![allow(unused)]
fn main() {
  let item_name = item.read().to_string();
  let author = author.read().to_string();
  let response = post_item(PostShopItem {
      title: item_name,
      posted_by: author,
  })
  .await;

  if response.is_ok() {
      change_signal.write();
  }
}

But that is sufficient for re-rendering other components subscribed to that signal (automatically when you have a read). Or to execute some logic on subscribed components withing hooks.

Reading the signal

Now let's subscribe to that signal by reading it inside our ShoppingList component's use_resource hook.

#![allow(unused)]
fn main() {
  let items_request = use_resource(move || async move {
      change_signal.read();
      get_items().await
  });
}

That is all you need to do in order let your components communicate with each other.

If you did it correctly, the list is fetched and updates just after you add a new item (if not - have a look at the solution)

Great! :)

Let's do the same to delete items in our next chapter.

Deleting Items

In this chapter we are going to clean up our frontend crate, as well as creating components, that will delete items from our shopping list.

Cleanup

As you already see, we have #[components] as well as helper functions in our code base. It would make sense to put all components inside a components module and helper functions to fetch and post (and later delete) items into a util/controller module.

Go ahead an put all Components and our ListChanged struct into a components.rs module, as well as helper functions for getting and posting data to our backend into a controllers.rs module.

Do also not forget to put the moved functions into the main.rs:

#![allow(unused)]
fn main() {
mod controllers;
mod components;
}

In order to use symbols across modules (structs and functions), you have to make those pub (public). In the end the main.rs function should only be left with our App function, the main entry point for our app.

Creating a helper function to delete items

Like in the previous examples (e.g. adding an item) we now add another helper function to delete items. Go to your new controllers.rs module in the frontend crate and create an async function delete_item, which takes an item_id, a &str as input.

#![allow(unused)]
fn main() {
pub async fn delete_item(item_id: &str) -> Result<(), reqwest::Error> {
    reqwest::Client::new()
        .delete(format!("http://localhost:3001/items/{}", item_id))
        .send()
        .await?;

    Ok(())
}
}

In this case we are just interested if the deletion was ok. If an error happens, the ?-Operator returns a reqwest::Error.

Create the component

Let's add a component to an already existing component! We plan to create a button, which is on top of our item in the list. So if you click the button on the respective item - this item then gets deleted.

Go ahead to your components.rs module and add a component with a button with a cross on it:

#![allow(unused)]
fn main() {
#[component]
fn ItemDeleteButton() -> Element {
    // We change this later
    let onclick = move |_| {};

    rsx! {
    button {
        onclick: onclick,
        class: "btn btn-circle",
            svg {
                class: "h-6 w-6",
                view_box: "0 0 24 24",
                stroke: "currentColor",
                stroke_width: "2",
                stroke_linecap: "round",
                stroke_linejoin: "round",
                fill: "none",
                path {
                    d: "M6 18L18 6M6 6l12 12"
                }
            }
        }
    }
}
}

If you add this component then to our ShoppingListItemComponent, then you can already see the new button.

#![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}"
            }
            ItemDeleteButton {}
        }
    }
}
}

Delete item onclick

We currrently do nothing. Let's implement the onclick action - so when we click the item, a delete request is sent to our backend.

At the top of your components.rs module:

#![allow(unused)]
fn main() {
use crate::controllers::{delete_item, get_items, post_item};
}

And the onclick in your ItemDeleteButton:

#![allow(unused)]
fn main() {
  let onclick = move |_| {
      spawn({
          let item_id = item_id.clone();
          async move {
              let _ = delete_item(&item_id).await;
          }
      });
  };
}

Of course, this does not work yet. Somehow you need an item_id. Let's add it as a component prop. Go ahead, and change the signature and the use of the following components:

ItemDeleteButton:

#![allow(unused)]
fn main() {
#[component]
fn ItemDeleteButton(item_id: String) -> Element {...}
}

ShoppingLisItemComponent:

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

And finally the ul in our ShoppingList component:

#![allow(unused)]
fn main() {
      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(),
                      item_id: i.uuid.clone()
                  },
              }
          }
      }
}

With this - you will now see in the developer settings in your browser, that you send delete requests to the backend.

But again: You do not see the list changing. We have to notify other components, that our list has changed. Let's add this in the next step.

Propagate our ListChanged signal to notify other components

In order to notify others, we introduced a ListChanged signal before. Let's reuse this, to write to it - after we successfully deleted an item. Let's propagate the signal to all involved components:

ItemDeleteButton:

#![allow(unused)]
fn main() {
#[component]
fn ItemDeleteButton(item_id: String, change_signal: Signal<ListChanged>) -> Element {
    let onclick = move |_| {
        spawn({
            let item_id = item_id.clone();
            async move {
                let response = delete_item(&item_id).await;
                if response.is_ok() {
                    change_signal.write();
                }
            }
        });
    };

    ...
}
}

ShoppingListItemComponent:

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

ShoppingList:

#![allow(unused)]
fn main() {
#[component]
pub fn ShoppingList(change_signal: Signal<ListChanged>) -> Element {
    let items_request = use_resource(move || async move {
        change_signal.read();
        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(),
                                item_id: i.uuid.clone(),
                                change_signal
                            },
                        }
                    }
                }
            }
        },
        Some(Err(err)) => {
            rsx! {
                p {
                    "Error: {err}"
                }
            }
        }
        None => {
            rsx! {
                p {
                    "Loading items..."
                }
            }
        }
    }
}
}

If you now run everything - you can now also delete items with the newly added button! As our use_resource hook will be rerun after we write to our signal, the latest list from the backend is fetched - after we deleted an item.

Small Recap

What did we do in this chapter:

  • Adding a new components
  • Delete items, when we click a button on this item
  • Propagate our signal and notify other components about our deletion

Add Routing

important distinction: in a SPA setup, we have two routing systems:

  1. The browser navigator (what you see in the URL bar)
  2. the routes for API endpoints

This might be obvious if you are familiar with SPA, but there are also different generations of server side rendered frameworks that just have one routing system, and where the routes in the URL bars are identical with what you request over network (= normal page load, like it's 1999). Sometimes, you can find combinations of the two separated by a #, e.g. your.backendroute.com/admin#users/2

We already saw API endpoint routes in action in the axum chapters, so this chapter is a about navigator routing, and we will use routes without any #.

Add the router feature to cargo.toml

dioxus = { version = "0.5.1", features = ["web", "router"] }

Create a new mock component (profile)

...and refactor your App component (call it e.g. "Home" instead)

#![allow(unused)]
fn main() {
pub fn Profile() -> Element {
    rsx! {
        div {
            div {
                class: "flex flex-col gap-4 w-full",
                div {
                    class: "flex gap-4 items-center",
                    div {
                        class: "skeleton w-16 h-16 rounded-full shrink-0"
                    }
                    div {
                        class: "flex flex-col hap-4",
                        div {
                            class: "skeleton h-4 w-20"
                        }
                        div {
                            class: "skeleton h-4 w-28"
                        }
                    }
                }
                div {
                    class: "skeleton h-32 w-full"
                }
                div {
                    class: "skeleton h-32 w-full"
                }
            }
        }
    }
}
}

Since we are just getting started with Routing and don't want to implement the profile page yet, we just use daisyui skeletons for a "under construction" site.

define and use a Router enum

#![allow(unused)]
fn main() {
#[derive(Routable, Clone)]
enum Route {
    #[route("/")]
    Home {},
    #[route("/profile")]
    Profile {}
}
}

This enums maps the route "/" to the Home component (what we had so far under App), and "/profile" to the new Profile page.

#![allow(unused)]
fn main() {
#[allow(non_snake_case)]
fn App() -> Element {
    rsx! {
        Router::<Route>{}
    }
}
}

We can use the router with our new App component, which now renders something different depending on the currently active route.

Try it in the URL bar! with localhost:8080/ and localhost:8080/profile to see your routes in action.

Add a navigation bar to the base layout

We could add Links to our routes, e.g. with Link { to: Route::Profile{}, "Profile" } for the profile page. Instead of repeating these all over as we build more and more pages, let's add a navigation bar to a common layout, which will appear on all pages.

A layout is a component which has an Outlet. An oulet is like a window through which you can see the router content, in our example everything below the navigation bar. At the same time, we can take care about some styling aspects for the layout, e.g. putting everything into a container with auto margins to the left and right.

The layout could look like this:

#![allow(unused)]
fn main() {
#[component]
pub fn Layout() -> Element {
    rsx! {
        div {
            class: "min-h-screen bg-base-300",
            div {
                class: "navbar flex",
                div {
                    Link { class: "p-4", to: Route::Home{}, "Home" }
                    Link { class: "p-4", to: Route::Profile{}, "Profile" }
                }
            }
            div { class: "container mx-auto max-w-[1024px] p-8",
                Outlet::<Route>{}
            }
        }
    }
}
}

and we update our route with the layout info:

#![allow(unused)]
fn main() {
#[derive(Routable, Clone)]
pub enum Route {
    #[layout(Layout)]
        #[route("/")]
        Home {},
        #[route("/profile")]
        Profile {}
}
}

The indentation as suggested by the dioxus docs is not very elegant, but it helps see the structure.

Routes and layouts are the building blocks for complex navigation patterns, they can even be nested e.g. for submenus. And they can contain dynamic placeholders.

Database unique lists

In the end you want share a list right? So we have to extend our database model.

Instead of having only a HashMap of items, let's have a HashMap of HashMap of items (that sounds already bad - but is more than sufficient for our workshop!) This way we can have unique lists - and a list is identifiable with its list_uuid.

Wrap a HashMap with a new struct

Go to your database.rs module and create a new struct. This is a list in our sense, that maps item uuids to ShoppingItems.

#![allow(unused)]
fn main() {
struct ShoppingList {
    list: HashMap<String, ShoppingItem>,
}
}

Let's also create some sane defaults, so our ShoppingList::default() already has some items:

#![allow(unused)]
fn main() {
impl Default for ShoppingList {
    fn default() -> Self {
        Self {
            list: [
                (
                    "6855cfc9-78fd-4b66-8671-f3c90ac2abd8".to_string(),
                    ShoppingItem {
                        title: "Coffee".to_string(),
                        creator: "Roland".to_string(),
                    },
                ),
                (
                    "3d778d1c-5a4e-400f-885d-10212027382d".to_string(),
                    ShoppingItem {
                        title: "Tomato Seeds".to_string(),
                        creator: "Tania".to_string(),
                    },
                ),
            ]
            .into(),
        }
    }
}
}

If you never came across the into() method: It transforms this []-array into the type of Self, which is a HashMap<String, ShoppingItem>.

Change InMemoryDatabase to wrap HashMap<String, ShoppingList>

In this chapter we create the outer HashMap - which we already have. Change our existing InMemoryDatabase.

#![allow(unused)]
fn main() {
pub struct InMemoryDatabase {
    inner: HashMap<String, ShoppingList>,
}
}

Also - change the Default implementation of InMemoryDatabase:

#![allow(unused)]
fn main() {
impl Default for InMemoryDatabase {
    fn default() -> Self {
        let mut inner = HashMap::new();
        inner.insert(
            "9e137e61-08ac-469d-be9d-6b3324dd20ad".to_string(),
            ShoppingList::default(),
        );
        InMemoryDatabase { inner }
    }
}
}

We now changed the members of our InMemoryDatabase. Let's change our API next.

Change the InMemoryDatabase API and implementation

As we are going to have more than one lists - when we for instance want to receive an item, we also have to know from which list we want to receive that item. So all our current APIs of the InMemoryDatabase now also need a list_uuid to an item_uuid.

Let's change our implementation and API at once and look at it in detail afterwards:

#![allow(unused)]
fn main() {
impl InMemoryDatabase {
    pub fn insert_item(&mut self, list_uuid: &str, item_uuid: &str, shopping_item: ShoppingItem) {
        self.inner
            .get_mut(list_uuid)
            .and_then(|list| list.list.insert(item_uuid.to_string(), shopping_item));
    }

    pub fn delete_item(&mut self, list_uuid: &str, item_uuid: &str) {
        self.inner
            .get_mut(list_uuid)
            .and_then(|list| list.list.remove(item_uuid));
    }

    pub fn create_list(&mut self, list_uuid: &str) {
        self.inner
            .insert(list_uuid.to_string(), ShoppingList::default());
    }

    fn get_list(&self, list_uuid: &str) -> Option<&ShoppingList> {
        self.inner.get(list_uuid)
    }

    pub fn as_vec(&self, list_uuid: &str) -> Vec<ShoppingListItem> {
        let list = self.get_list(list_uuid);
        match list {
            Some(list) => list
                .list
                .iter()
                .map(|(key, item)| ShoppingListItem {
                    title: item.title.clone(),
                    posted_by: item.creator.clone(),
                    uuid: key.clone(),
                })
                .collect(),
            None => Vec::default(),
        }
    }
}
}

If you ask yourself now - what the hell is and_then? It allows you to chain calls together. For example look at the new delete_item method. We first try to receive a list, given a list_uuid. That can return Some(list), or None, if we do not have list with that given uuid. If it is something, then and_then will continue with whatever is given to it as closure.

If the get_mut would return None, then the and_then is a noop, doing nothing.

If you noticed: We also added two new methods - get_list and create_list. Of course, when we want to create a new list, we have to enter an entry in our outer HashMap. The get_list is used only internally in the as_vec method.

Making things compile again.

We changed a lot. Our new API needs a list_uuid. For now, let's hardcode that with a fixed value, wherever the API is used - and make it adaptable later. Go to your controllers.rs module in the backend - and hardcode our list_uuid

#![allow(unused)]
fn main() {
const LIST_UUID: &str = "9e137e61-08ac-469d-be9d-6b3324dd20ad";
}

and use it everywhere, a list_uuid is needed. We will change it later, when we have a list_uuid available.

Below the changed controllers:

#![allow(unused)]
fn main() {
pub async fn get_items(State(state): State<Database>) -> impl IntoResponse {
    let items: Vec<ShoppingListItem> = state.read().unwrap().as_vec(LIST_UUID);

    Json(items)
}
}
#![allow(unused)]
fn main() {
pub async fn add_item(
    State(state): State<Database>,
    Json(post_request): Json<PostShopItem>,
) -> impl IntoResponse {
    let item = ShoppingItem {
        title: post_request.title.clone(),
        creator: post_request.posted_by.clone(),
    };
    let item_uuid = Uuid::new_v4().to_string();

    let Ok(mut db) = state.write() else {
        return (StatusCode::SERVICE_UNAVAILABLE).into_response();
    };

    db.insert_item(LIST_UUID, &item_uuid, item);

    (
        StatusCode::OK,
        Json(ShoppingListItem {
            title: post_request.title,
            posted_by: post_request.posted_by,
            uuid: item_uuid,
        }),
    )
        .into_response()
}
}
#![allow(unused)]
fn main() {
pub async fn delete_item(
    State(state): State<Database>,
    Path(uuid): Path<Uuid>,
) -> impl IntoResponse {
    let Ok(mut db) = state.write() else {
        return StatusCode::SERVICE_UNAVAILABLE;
    };

    db.delete_item(LIST_UUID, &uuid.to_string());

    StatusCode::OK
}
}

If you run everything - nothing should have changed. Hardcoding values is not nice. We will fix that issue in the next chapter, when we change our backend API. We also add new routes, such as creating a list.

Backend unique lists

We changed our database - so it's able to handle different lists with different shopping items. But currently we have only one list - as its ID is hardcoded. Let's change that in this chapter. We expand our REST api in such a way, so we can create new lists and load existing ones, without needed to hardcode an ID (in the backend).

Changing the routes

We currently had the following route: /items. But now that we can also have different lists, we should rename our exising routes.

Go to our main.rs in the backend - and change the routes from:

  • /items to /list/:list_uuid/items
  • /items/:uuid to /list/:list_uuid/items/:item_uuid

Each item is now parametrized by a list, or a subresource of the list.

#![allow(unused)]
fn main() {
let app = Router::new()
    .route("/list/:list_uuid/items", get(get_items).post(add_item))
    .route("/list/:list_uuid/items/:item_uuid", delete(delete_item))
    .layer(CorsLayer::permissive())
    .with_state(db);
}

The :list_uuid will be extracted from the path and is from now on available in our controllers in the controllers.rs module. Perfect! Now let's use the new variable inside our controllers and get rid of the hardcoded LIST_UUID in the controllers.rs module.

Using new Path parameters

Now with the new routes, we can extract the extra list_uuid parameter - and use it in our controllers. If you have two path parameters, like in delete_item, you now have to extract path parameters as a tuple. The names inside the tuple bind to the names given in your route path.

Let's start with the delete_item, as it's the hardest (path wise):

#![allow(unused)]
fn main() {
pub async fn delete_item(
    State(state): State<Database>,
    Path((list_uuid, item_uuid)): Path<(Uuid, Uuid)>,
) -> impl IntoResponse {
    let Ok(mut db) = state.write() else {
        return StatusCode::SERVICE_UNAVAILABLE;
    };

    db.delete_item(&list_uuid.to_string(), &item_uuid.to_string());

    StatusCode::OK
}
}

Like stated above - your Path extractor now needs to work with a tuple of type (Uuid,Uuid). So if one of those parameters is not parsable as an uuid, we will get a 400 error automatically (invalid input).

Let's change the other existing controllers respectively:

#![allow(unused)]
fn main() {
pub async fn add_item(
    Path(list_uuid): Path<Uuid>,
    State(state): State<Database>,
    Json(post_request): Json<PostShopItem>,
) -> impl IntoResponse {
    let item = ShoppingItem {
        title: post_request.title.clone(),
        creator: post_request.posted_by.clone(),
    };
    let item_uuid = Uuid::new_v4().to_string();

    let Ok(mut db) = state.write() else {
        return (StatusCode::SERVICE_UNAVAILABLE).into_response();
    };

    db.insert_item(&list_uuid.to_string(), &item_uuid, item);

    (
        StatusCode::OK,
        Json(ShoppingListItem {
            title: post_request.title,
            posted_by: post_request.posted_by,
            uuid: item_uuid,
        }),
    )
        .into_response()
}
}

and

#![allow(unused)]
fn main() {
pub async fn get_items(
    Path(list_uuid): Path<Uuid>,
    State(state): State<Database>,
) -> impl IntoResponse {
    let items: Vec<ShoppingListItem> = state.read().unwrap().as_vec(&list_uuid.to_string());

    Json(items)
}
}

Great! Now we adapted our existing controllers and already use the new list_uuid. You can now also safely delete the hardcoded LIST_UUID value ;).

Adding new routes

Now that we adapted the existing routes, let's add a new one to create a fresh list. As we only have to request a /list endpoint without to provide any data - we can get away with a GET endpoint. Usually, when you want to create new resources server side, you would supply a post with some data. But for our case we do not need this.

What we need though is a response for this endpoint, as we want to know the list_uuid of the newly created list.

Go to the model create and add a new serializable and deserializable model to the lib.rs:

#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize)]
pub struct CreateListResponse {
    pub uuid: String,
}
}

This model will also be deserialized in the frontend later. So also put a Deserialize from serde on top of the struct.

Go ahead an create a new async function in the controller.rs module in our backend crate.

#![allow(unused)]
fn main() {
use model::{CreateListResponse, PostShopItem, ShoppingListItem}; // import what is needed.

pub async fn create_shopping_list(State(state): State<Database>) -> impl IntoResponse {
    let uuid = Uuid::new_v4().to_string();
    state.write().unwrap().create_list(&uuid);

    Json(CreateListResponse { uuid })
}
}

The last thing remaining is to put the create_shopping_list into our Router. You know the drill! Add it to the router with a /list route - fetched via a GET.

#![allow(unused)]
fn main() {
.route("/list", get(create_shopping_list))
}

We are getting somewhere! :). Let's hop on to change the frontend also, to get/post and delete with the new routes, we just created.

Frontend: new Endpoints

In this chapter we will do the following:

  • Creating a helper to create a new list
  • Adapt our controller functions to the new routes, we defined in the backend - so we can build again :)

Creating a helper to create a new list

In order to create a new list, we have to write a new controller function and add it to our controllers.rs module. As you already know, how to do this one - go ahead and this to your controllers.rs:

#![allow(unused)]
fn main() {
async fn create_list() -> Result<CreateListResponse, reqwest::Error> {
    let response = reqwest::Client::new()
        .get("http://localhost:3001/list")
        .send()
        .await?
        .json::<CreateListResponse>()
        .await?;

    Ok(response)
}
}

This will make a GET request to our /list endpoint - and in return we will receive an uuid for our new list.

Adapt controllers to new endpoints

As you remember - we changed our endpoints in our backend. Go ahead and change the other controllers. As an example we look at the existing delete_item function.

#![allow(unused)]
fn main() {
pub async fn delete_item(item_id: &str) -> Result<(), reqwest::Error> {
    reqwest::Client::new()
        .delete(format!("http://localhost:3001/items/{}", item_id))
        .send()
        .await?;

    Ok(())
}
}

As we now have to know in which list we want to delete the item from, we have to expand the arguments list for a list_id (the lists uuid). You can just append the argument to the format!(..) string. In the end, the function should look like this:

#![allow(unused)]
fn main() {
pub async fn delete_item(list_id: &str, item_id: &str) -> Result<(), reqwest::Error> {
    reqwest::Client::new()
        .delete(format!(
            "http://localhost:3001/list/{}/items/{}",
            list_id, item_id
        ))
        .send()
        .await?;

    Ok(())
}
}

As the other functions are kind of straightforward - go ahead and change the other functions that are still tuned to the "old" REST api.

#![allow(unused)]
fn main() {
pub async fn get_items(list_id: &str) -> Result<Vec<ShoppingListItem>, reqwest::Error> {
    let url = format!("http://localhost:3001/list/{}/items", list_id);
    let list = reqwest::get(&url)
        .await?
        .json::<Vec<ShoppingListItem>>()
        .await;

    list
}

pub async fn post_item(
    list_id: &str,
    item: PostShopItem,
) -> Result<ShoppingListItem, reqwest::Error> {
    let response = reqwest::Client::new()
        .post(format!("http://localhost:3001/list/{}/items", list_id))
        .json(&item)
        .send()
        .await?
        .json::<ShoppingListItem>()
        .await?;

    Ok(response)
}
}

Propagate the list uuid from top to down

Now we need the list's uuid everywhere, where we fetch data from the backend. So somehow we need a uuid for this. Do you have a guess how?

Go to your Home component and add a list_uuid signal there. For now we hardcode the value to be 9e137e61-08ac-469d-be9d-6b3324dd20ad (the first existing list in our backend - if you remember - also hardcoded ;)). The idea now: propagate down this list_uuid to all components, that need a list_uuid, which are the ShoppingList and the ItemInput components (and all the components they use).

#![allow(unused)]
fn main() {
#[component]
pub fn Home() -> Element {
    let list_uuid = use_signal(|| "9e137e61-08ac-469d-be9d-6b3324dd20ad".to_string());
    let change_signal = use_signal(|| ListChanged);
    rsx! {
        ShoppingList{list_uuid, change_signal}
        ItemInput{list_uuid, change_signal}
    }
}
}

Of course, those components have to be adjusted in their parameters (or so called: Component props). Go ahead and change the props of those. Then you can use the list_uuid in the requests fired by the hooks used in the components. The easiest way to accomplish this is forwarding the signal, and read it inside the component.

To spare you more headaches - use these adjusted components. The only thing that changed, is that they now all use the added list_uuid.

ShoppingListItemComponent

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

ShoppingList

#![allow(unused)]
fn main() {
#[component]
pub fn ShoppingList(list_uuid: Signal<String>, change_signal: Signal<ListChanged>) -> Element {
    let items_request = use_resource(move || async move {
        change_signal.read();
        get_items(list_uuid.read().as_str()).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(),
                                list_uuid,
                                item_id: i.uuid.clone(),
                                change_signal
                            },
                        }
                    }
                }
            }
        },
        Some(Err(err)) => {
            rsx! {
                p {
                    "Error: {err}"
                }
            }
        }
        None => {
            rsx! {
                p {
                    "Loading items..."
                }
            }
        }
    }
}
}

ItemInput

#![allow(unused)]
fn main() {
#[component]
pub fn ItemInput(list_uuid: Signal<String>, change_signal: Signal<ListChanged>) -> Element {
    let mut item = use_signal(|| "".to_string());
    let mut author = use_signal(|| "".to_string());

    let onsubmit = move |_| {
        spawn({
            async move {
                let item_name = item.read().to_string();
                let author = author.read().to_string();
                let response = post_item(
                    list_uuid.read().as_str(),
                    PostShopItem {
                        title: item_name,
                        posted_by: author,
                    },
                )
                .await;

                if response.is_ok() {
                    change_signal.write();
                }
            }
        });
    };

...
}
}

ItemDeleteButton

#![allow(unused)]
fn main() {
#[component]
fn ItemDeleteButton(
    list_uuid: String,
    item_id: String,
    change_signal: Signal<ListChanged>,
) -> Element {
    let onclick = move |_| {
        spawn({
            let list_uuid = list_uuid.clone();
            let item_id = item_id.clone();
            async move {
                let response = delete_item(&list_uuid, &item_id).await;
                if response.is_ok() {
                    change_signal.write();
                }
            }
        });
    };

    rsx! {
    button {
        onclick: onclick,
        class: "btn btn-circle",
            svg {
                class: "h-6 w-6",
                view_box: "0 0 24 24",
                stroke: "currentColor",
                stroke_width: "2",
                stroke_linecap: "round",
                stroke_linejoin: "round",
                fill: "none",
                path {
                    d: "M6 18L18 6M6 6l12 12"
                }
            }
        }
    }
}
}

After you changed these components - your frontend should build again by the way :) You can test it with again with

cargo make --no-workspace dev

In the next chapter we will add a new component, that creates a new list or gets a list uuid by user input.

You almost made it :) hang on!

Frontend: Load or Create - that is the Question

You almost made it. In this chapter we write our component to either load or create a list. The idea:

  • We get a uuid by input or we get a uuid by creating a new list in the backend
  • We then route to our main Home component to with a list_uuid.

Add a dynamic route

In order to route dynamically to our main Home component two things have to be done.

  1. We need a dynamic route, something like /list/<uuid>.
  2. This <uuid> parameter then has the be given to our Home component.

Let's start with our Home component. Add a list_uuid component property.

#![allow(unused)]
fn main() {
#[component]
pub fn Home(list_uuid: String) -> Element {
    let list_uuid = use_signal(|| list_uuid);
    let change_signal = use_signal(|| ListChanged);
    rsx! {
        ShoppingList{list_uuid, change_signal}
        ItemInput{list_uuid, change_signal}
    }
}
}

And you might have noticed - we removed our hardcoded uuid :).

Then we also need to have a dynamic route. Go to our Route enum (in our frontend).

#![allow(unused)]
fn main() {
#[derive(Routable, Clone)]
pub enum Route {
    #[layout(Layout)]
    #[route("/list/:list_uuid")]
    Home { list_uuid: String },
    #[route("/profile")]
    Profile {},
}
}

Great! with this we are now dynamically routable. Let's create a new component that will route to our new Home with a list_uuid.

Create a new list

Let's create a new component, that load or create a new list. You also see the use_navigator hook. It allows is to route to other pages we have!

#![allow(unused)]
fn main() {
#[component]
pub fn LoadOrCreateList() -> Element {
    let nav = use_navigator();

    let on_create_list_click = move |_| {
        let nav = nav.clone();
        spawn({
            async move {
                let response = create_list().await;
                if let Ok(created_list) = response {
                    nav.push(Route::Home {
                        list_uuid: created_list.uuid,
                    });
                }
            }
        });
    };

    rsx! {
        div{
            class: "grid place-content-evently grid-cols-1 md:grid-cols-2 w-full gap-4",
            div {
                class: "card glass min-h-500 flex flex-col content-end gap-4 p-4",
                button{
                    class: "btn btn-primary",
                    onclick: on_create_list_click,
                    "Create new List"
                }
            }
        }
    }
}
}

Add this new component to the Route as this is now the first page we see, when we open up our url:

#![allow(unused)]
fn main() {
#[derive(Routable, Clone)]
pub enum Route {
    #[layout(Layout)]
    #[route("/")]
    LoadOrCreateList {},
    #[route("/list/:list_uuid")]
    Home { list_uuid: String },
    #[route("/profile")]
    Profile {},
}
}

If you then also route correctly in our Layout then we have a working routing in our frontend :) We can only create new list's though

#![allow(unused)]
fn main() {
#[component]
pub fn Layout() -> Element {
    rsx! {
        div {
            class: "min-h-screen bg-base-300",
            div {
                class: "navbar flex",
                div {
                    Link { class: "p-4", to: Route::LoadOrCreateList{}, "Home" }
                    Link { class: "p-4", to: Route::Profile{}, "Profile" }
                }
            }
            div { class: "container mx-auto max-w-[1024px] p-8",
                Outlet::<Route>{}
            }
        }
    }
}
}

If you now run all the code - you can create a new list with a click of a button - and it will route you to the page with the freshly created list.

Load a list from given Uuid

In this chapter, we create a form. Here the user has to input a given uuid. Then we forward with the given uuid. Let's expand our LoadOrCreateList our component! As you already have seen how to create a form - we fast forward.

What we've changed:

  • We have a input form where we can input a string.
  • That string get copied to our list_uuid Signal.
  • On clicking the Load existing list button we read the stored list_uuid and route to our Home again with this uuid.
#![allow(unused)]
fn main() {
#[component]
pub fn LoadOrCreateList() -> Element {
    let nav = use_navigator();
    let mut list_uuid = use_signal(|| "".to_string());

    let onloadsubmit = move |_| {
        spawn({
            async move {
                let uuid_value = list_uuid.read().clone();
                if !uuid_value.is_empty() {
                    nav.push(Route::Home {
                        list_uuid: uuid_value,
                    });
                }
            }
        });
    };

    let on_create_list_click = move |_| {
        let nav = nav.clone();
        spawn({
            async move {
                let response = create_list().await;
                if let Ok(created_list) = response {
                    nav.push(Route::Home {
                        list_uuid: created_list.uuid,
                    });
                }
            }
        });
    };

    rsx! {
        div{
            class: "grid place-content-evently grid-cols-1 md:grid-cols-2 w-full gap-4",
            div {
                class: "card glass min-h-500 flex flex-col content-end gap-4 p-4",
                button{
                    class: "btn btn-primary",
                    onclick: on_create_list_click,
                    "Create new List"
                }
            }
            div { class: "card glass min-h-500",
                form {
                    onsubmit: onloadsubmit,
                    div {
                        class: "flex flex-col gap-4 p-4",
                        input{
                            class:"input input-bordered",
                            r#type:"text",
                            placeholder:"Enter UUID here...",
                            id: "uuid",
                            name: "uuid",
                            oninput: move |e| list_uuid.set(e.data.value())
                        }
                        button{
                            class: "btn btn-primary",
                            r#type: "submit",
                            "Load existing List"
                        }
                    }
                }
            }
        }
    }
}
}

Nice! You've done it! :) If you want - you can stay for some styling.