Full-stack Rust: A complete tutorial with examples - LogRocket Blog (2024)

Previously on this blog, we covered how to create a CRUD web service with Rust using warp and how to build a frontend web app with Rust using Yew.

Full-stack Rust: A complete tutorial with examples - LogRocket Blog (1)

In this tutorial, we’ll put it all together and build a simple full stack web application, featuring a database-backed REST backend and a Wasm-based single-page application on the frontend, which calls this backend.

To top it all off, we’ll create a shared Rust module, which will be used by both the frontend and the backend, to demonstrate how to share code in such a setup.

We’ll build a very simple pet owner app that enables the user to add owners and their pets. Our app will feature a detailed view for owners and their list of pets, enabling them to delete and add pets as needed.

Here’s what we’ll cover:

  • Setting up a full-stack Rust app
  • Common functionality
  • Building the REST backend
  • Frontend implementation
  • Testing our Rust full-stack app

You don’t need to have read the aforementioned posts to follow along, but since this post includes both concepts, we won’t go into the same level of depth regarding the basics. If you want to dive deeper, feel free to scan over them.

Without further ado, let’s get started!

Setting up a full-stack Rust app

To follow along, all you need is a reasonably recent Rust installation. Docker, or some other way of running a Postgres database, would also be useful.

In this case, since we’re going to write both the backend and frontend in Rust, and we’re going to share some code between them, we’ll create a multimember workspace project using Cargo.

First, create a new Rust project:

cargo new rust-fullstack-examplecd rust-fullstack-example

Then, delete the src folder and edit the Cargo.toml file as follows:

[workspace]members = [ "backend", "frontend", #Internal "common"]

Now we can create our three separate Rust projects:

cargo new --lib commoncargo new backendcargo new --lib frontend

Navigate to the common directory and edit the Cargo.toml file, adding the following dependencies:

[dependencies]serde = {version = "=1.0.126", features = ["derive"] }

Next, edit the Cargo.toml file in frontend and add these dependencies:

[dependencies]yew = "0.18"wasm-bindgen = "0.2.67"serde_json = "1"serde = {version = "=1.0.126", features = ["derive"] }anyhow = "1"yew-router = "0.15.0"common = { version = "0.1.0", path = "../common" }

We’re using Yew to build the Wasm-based frontend. We add some more utility libraries for routing and error and JSON handling, as well as an internal dependency to our common library, which will hold the code shared between the frontend and backend.

Finally, edit the Cargo.toml file in backend and add these dependencies:

[dependencies]tokio = { version = "=1.6.1", features = ["macros", "rt-multi-thread"] }warp = "=0.3.1"mobc = "=0.7.2"mobc-postgres = { version = "=0.7.0", features = ["with-chrono-0_4", "with-serde_json-1"] }serde = {version = "=1.0.126", features = ["derive"] }serde_json = "=1.0.64"thiserror = "=1.0.24"common = { version = "0.1.0", path = "../common" }

We’re using the warp web framework to build the backend. Since we’re using a Postgres database to store our data, we’ll also add the mobc connection pool for Postgres.

Besides that, since Warp is optimized for Tokio, we need to add it as our async runtime. We’ll add some utility libraries for error and JSON handling, as well as an internal dependency to our common project.

That’s it for the setup. Let’s start writing the shared code for both frontend and backend in our common project.

Common functionality

We’ll start off by fleshing out the common module, where we’ll add the shared data models between the frontend and the backend. In a real application, much more could be shared — including validation, helpers, utilities etc. — but, in this case, we’ll stick to the data structs.

In lib.rs, we’ll add the data models for our Owner and Pet models:

use serde::{Deserialize, Serialize};#[derive(Deserialize, Clone, PartialEq, Debug)]pub struct Owner { pub id: i32, pub name: String,}#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]pub struct OwnerRequest { pub name: String,}#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]pub struct OwnerResponse { pub id: i32, pub name: String,}impl OwnerResponse { pub fn of(owner: Owner) -> OwnerResponse { OwnerResponse { id: owner.id, name: owner.name, } }}#[derive(Deserialize, Clone, PartialEq, Debug)]pub struct Pet { pub id: i32, pub name: String, pub owner_id: i32, pub animal_type: String, pub color: Option<String>,}#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]pub struct PetRequest { pub name: String, pub animal_type: String, pub color: Option<String>,}#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]pub struct PetResponse { pub id: i32, pub name: String, pub animal_type: String, pub color: Option<String>,}impl PetResponse { pub fn of(pet: Pet) -> PetResponse { PetResponse { id: pet.id, name: pet.name, animal_type: pet.animal_type, color: pet.color, } }}

We define the database domain structs Owner and Pet, as well as the request and response data objects, we’ll use to communicate between the frontend and the backend.

Sharing this code is nice because adding or removing a field somewhere in the API will give us a compile error in the frontend if we don’t make accommodations for the change. This might save us some time chasing an error when an API is updated.

The Owner is very simple, with only a name and the database ID. The Pet type has a name, an animal_type, and an optional color.

Over 200k developers use LogRocket to create better digital experiencesLearn more →

We also define some helpers to create our data objects for the API from the database domain objects.

This is all we’ll put in the common project.

Let’s continue with the backend part of our app.

Building the REST backend

We start off with the database definition for our data model:

CREATE TABLE IF NOT EXISTS owner( id SERIAL PRIMARY KEY NOT NULL, name VARCHAR(255) NOT NULL);CREATE TABLE IF NOT EXISTS pet( id SERIAL PRIMARY KEY NOT NULL, owner_id INT NOT NULL, name VARCHAR(255) NOT NULL, animal_type VARCHAR(255) NOT NULL, color VARCHAR(255), CONSTRAINT fk_pet_owner_id FOREIGN KEY (owner_id) REFERENCES pet(id));

This defines our two data tables with their respective fields.

We’ll build the backend from the bottom up, starting with the database layer and working our way up to the web server and routing definitions.

First, we’ll create a db module. In here, we’ll start with some database and connection pool initialization code in mod.rs:

type Result<T> = std::result::Result<T, error::Error>;const DB_POOL_MAX_OPEN: u64 = 32;const DB_POOL_MAX_IDLE: u64 = 8;const DB_POOL_TIMEOUT_SECONDS: u64 = 15;const INIT_SQL: &str = "./db.sql";pub async fn init_db(db_pool: &DBPool) -> Result<()> { let init_file = fs::read_to_string(INIT_SQL)?; let con = get_db_con(db_pool).await?; con.batch_execute(init_file.as_str()) .await .map_err(DBInitError)?; Ok(())}pub async fn get_db_con(db_pool: &DBPool) -> Result<DBCon> { db_pool.get().await.map_err(DBPoolError)}pub fn create_pool() -> std::result::Result<DBPool, mobc::Error<Error>> { let config = Config::from_str("postgres://[emailprotected]:7878/postgres")?; let manager = PgConnectionManager::new(config, NoTls); Ok(Pool::builder() .max_open(DB_POOL_MAX_OPEN) .max_idle(DB_POOL_MAX_IDLE) .get_timeout(Some(Duration::from_secs(DB_POOL_TIMEOUT_SECONDS))) .build(manager))}

In init_db, we read the aforementioned db.sql file and execute it to initialize our tables.

The create_pool and get_db_con helpers are there to initialize the database pool and to get a new connection from the pool.

More great articles from LogRocket:

  • Don't miss a moment with The Replay, a curated newsletter from LogRocket
  • Learn how LogRocket's Galileo cuts through the noise to proactively resolve issues in your app
  • Use React's useEffect to optimize your application's performance
  • Switch between multiple versions of Node
  • Discover how to use the React children prop with TypeScript
  • Explore creating a custom mouse cursor with CSS
  • Advisory boards aren’t just for executives. Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.

With those setup details out of the way, let’s look at our first domain access object in owner.rs.

pub const TABLE: &str = "owner";const SELECT_FIELDS: &str = "id, name";pub async fn fetch(db_pool: &DBPool) -> Result<Vec<Owner>> { let con = get_db_con(db_pool).await?; let query = format!("SELECT {} FROM {}", SELECT_FIELDS, TABLE); let rows = con.query(query.as_str(), &[]).await.map_err(DBQueryError)?; Ok(rows.iter().map(|r| row_to_owner(&r)).collect())}pub async fn fetch_one(db_pool: &DBPool, id: i32) -> Result<Owner> { let con = get_db_con(db_pool).await?; let query = format!("SELECT {} FROM {} WHERE id = $1", SELECT_FIELDS, TABLE); let row = con .query_one(query.as_str(), &[&id]) .await .map_err(DBQueryError)?; Ok(row_to_owner(&row))}pub async fn create(db_pool: &DBPool, body: OwnerRequest) -> Result<Owner> { let con = get_db_con(db_pool).await?; let query = format!("INSERT INTO {} (name) VALUES ($1) RETURNING *", TABLE); let row = con .query_one(query.as_str(), &[&body.name]) .await .map_err(DBQueryError)?; Ok(row_to_owner(&row))}fn row_to_owner(row: &Row) -> Owner { let id: i32 = row.get(0); let name: String = row.get(1); Owner { id, name }}

There are three database operations for owners:

  1. fetch fetches all owners
  2. fetch_one fetches the owner with a given ID
  3. create creates a new owner

The implementation of these methods is rather straightforward. At first, we get a connection from the pool, then we define the Postgres query to be executed and execute it with the given values, propagating any errors.

Finally, we use the row_to_owner helper to convert the returned database row data to an actual Owner struct.

The pet.rs data access object is quite similar:

pub const TABLE: &str = "pet";const SELECT_FIELDS: &str = "id, owner_id, name, animal_type, color";pub async fn fetch(db_pool: &DBPool, owner_id: i32) -> Result<Vec<Pet>> { let con = get_db_con(db_pool).await?; let query = format!( "SELECT {} FROM {} WHERE owner_id = $1", SELECT_FIELDS, TABLE ); let rows = con .query(query.as_str(), &[&owner_id]) .await .map_err(DBQueryError)?; Ok(rows.iter().map(|r| row_to_pet(&r)).collect())}pub async fn create(db_pool: &DBPool, owner_id: i32, body: PetRequest) -> Result<Pet> { let con = get_db_con(db_pool).await?; let query = format!( "INSERT INTO {} (name, owner_id, animal_type, color) VALUES ($1, $2, $3, $4) RETURNING *", TABLE ); let row = con .query_one( query.as_str(), &[&body.name, &owner_id, &body.animal_type, &body.color], ) .await .map_err(DBQueryError)?; Ok(row_to_pet(&row))}pub async fn delete(db_pool: &DBPool, owner_id: i32, id: i32) -> Result<u64> { let con = get_db_con(db_pool).await?; let query = format!("DELETE FROM {} WHERE id = $1 AND owner_id = $2", TABLE); con.execute(query.as_str(), &[&id, &owner_id]) .await .map_err(DBQueryError)}fn row_to_pet(row: &Row) -> Pet { let id: i32 = row.get(0); let owner_id: i32 = row.get(1); let name: String = row.get(2); let animal_type: String = row.get(3); let color: Option<String> = row.get(4); Pet { id, name, owner_id, animal_type, color, }}

Here we have the following three methods:

  1. fetch fetches all pets belonging to a given owner_id
  2. create creates a new pet for the given owner_id
  3. delete deletes the pet with the given id and owner_id

In terms of implementation, it follows the exact same concept as owner.rs above.

This concludes the database layer. Let’s move one step up and implement handler.rs in src.

pub async fn list_pets_handler(owner_id: i32, db_pool: DBPool) -> Result<impl Reply> { let pets = db::pet::fetch(&db_pool, owner_id) .await .map_err(reject::custom)?; Ok(json::<Vec<_>>( &pets.into_iter().map(PetResponse::of).collect(), ))}pub async fn create_pet_handler( owner_id: i32, body: PetRequest, db_pool: DBPool,) -> Result<impl Reply> { Ok(json(&PetResponse::of( db::pet::create(&db_pool, owner_id, body) .await .map_err(reject::custom)?, )))}pub async fn delete_pet_handler(owner_id: i32, id: i32, db_pool: DBPool) -> Result<impl Reply> { db::pet::delete(&db_pool, owner_id, id) .await .map_err(reject::custom)?; Ok(StatusCode::OK)}pub async fn list_owners_handler(db_pool: DBPool) -> Result<impl Reply> { let owners = db::owner::fetch(&db_pool).await.map_err(reject::custom)?; Ok(json::<Vec<_>>( &owners.into_iter().map(OwnerResponse::of).collect(), ))}pub async fn fetch_owner_handler(id: i32, db_pool: DBPool) -> Result<impl Reply> { let owner = db::owner::fetch_one(&db_pool, id) .await .map_err(reject::custom)?; Ok(json(&OwnerResponse::of(owner)))}pub async fn create_owner_handler(body: OwnerRequest, db_pool: DBPool) -> Result<impl Reply> { Ok(json(&OwnerResponse::of( db::owner::create(&db_pool, body) .await .map_err(reject::custom)?, )))}

The API-surface consists of six operations:

  1. List owners
  2. Fetch owner for a given ID
  3. Create owner
  4. Create pet
  5. Delete pet
  6. List pets for a given owner

In each case, we simply call the corresponding operation in our database layer and convert the returned Owner, or Pet into an OwnerResponse or PetResponse, respectively, returning any errors directly to the caller.

Finally, moving yet another step up, we implement the actual web server pointing to these handlers in main.rs.

mod db;mod error;mod handler;type Result<T> = std::result::Result<T, Rejection>;type DBCon = Connection<PgConnectionManager<NoTls>>;type DBPool = Pool<PgConnectionManager<NoTls>>;#[tokio::main]async fn main() { let db_pool = db::create_pool().expect("database pool can be created"); db::init_db(&db_pool) .await .expect("database can be initialized"); let pet = warp::path!("owner" / i32 / "pet"); let pet_param = warp::path!("owner" / i32 / "pet" / i32); let owner = warp::path("owner"); let pet_routes = pet .and(warp::get()) .and(with_db(db_pool.clone())) .and_then(handler::list_pets_handler) .or(pet .and(warp::post()) .and(warp::body::json()) .and(with_db(db_pool.clone())) .and_then(handler::create_pet_handler)) .or(pet_param .and(warp::delete()) .and(with_db(db_pool.clone())) .and_then(handler::delete_pet_handler)); let owner_routes = owner .and(warp::get()) .and(warp::path::param()) .and(with_db(db_pool.clone())) .and_then(handler::fetch_owner_handler) .or(owner .and(warp::get()) .and(with_db(db_pool.clone())) .and_then(handler::list_owners_handler)) .or(owner .and(warp::post()) .and(warp::body::json()) .and(with_db(db_pool.clone())) .and_then(handler::create_owner_handler)); let routes = pet_routes .or(owner_routes) .recover(error::handle_rejection) .with( warp::cors() .allow_credentials(true) .allow_methods(&[ Method::OPTIONS, Method::GET, Method::POST, Method::DELETE, Method::PUT, ]) .allow_headers(vec![header::CONTENT_TYPE, header::ACCEPT]) .expose_headers(vec![header::LINK]) .max_age(300) .allow_any_origin(), ); warp::serve(routes).run(([127, 0, 0, 1], 8000)).await;}fn with_db(db_pool: DBPool) -> impl Filter<Extract = (DBPool,), Error = Infallible> + Clone { warp::any().map(move || db_pool.clone())}

There’s quite a bit to unpack, so let’s go through it.

Initially, we define the modules and some types to save time typing. Then, in the main function (or tokio::main, the asynchronous entry point of our application), we first initialize the database pool and database.

At the bottom, there is a with_db filter, which is the preferred way in warp to pass data to a handler — in this case, the connection pool.

Then we define several routing bases for pet, which has the form of /owner/$ownerId/pet; pet_param, which adds a /$petId at the end; and owner, which simply contains /owner.

With these bases, we can define our routes, leading to the different handlers:

  • GET /owner lists all owners
  • GET /owner/$ownerId returns owner with the given ID
  • POST /owner creates an owner
  • GET /owner/$ownerid/pet lists all pets of the given owner
  • POST /owner/$ownerId/pet creates a pet for the given owner
  • DELETE /owner/$ownerId/pet/$petId deletes the pet with the given ID and owner ID

Then we wire everything together with a CORS config and run the server on port 8000.

This concludes the backend. You can run it by simply running cargo run and, provided you have a Postgres database running at port 7878 (for example, using Docker), you’ll have the REST API running on http://localhost:8000.

You can test it using cURL by running commands such as this:

curl -X POST http://localhost:8000/owner -d '{"name": "mario"}' -H 'content-type: application/json'curl -v -X POST http://localhost:8000/owner/1/pet -d '{"name": "minka", "animal_type": "cat", "color": "black-brown-white"}' -H 'content-type: application/json'

Frontend implementation

Now that we have a fully functional backend, we need a way to interact with it.

In the case of the frontend, we’ll start from the top in lib.rs and work our way down through the components because it’s more natural to go through the component tree step by step.

We’ll use yew_router for routing. Otherwise, we’ll use the same setup as the official Yew docs suggest, using trunk to build and serve the web app.

Within our app, there are two modules, pet and owner. However, before we start writing any Rust code, we need to create our index.html file in our frontend project root, including the styles we’ll use:

<html> <head> <title>Rust Fullstack Example</title> <style> body { font-size: 14px; font-family: sans-serif; } a { text-decoration: none; color: #339; } a:hover { text-decoration: none; color: #33f; } .app { background-color: #efefef; margin: 100px 25% 25% 25%; width: 50%; padding: 10px; } .app .nav { text-align: center; font-size: 16px; font-weight: bold; } .app .refresh { text-align: center; margin: 10px 0 10px 0; } .list-item { margin: 2px; padding: 5px; background-color: #cfc; } .pet { margin-top: 10px; } .completed { text-decoration: line-through; background-color: #dedede; } .detail { font-size: 16px; } .detail h1 { font-size: 24px; } .detail .id { color: #999; } .detail .completed { color: #3f3; } .detail .not-completed { color: #f33; } </style> </head></html>

This HTML file will be used as a starting point and trunk will add the corresponding snippets to make our app work with it in the dist folder when we build the app.

Start at the root

Let’s start at the top with lib.rs.

We first define some modules and a struct for containing our root component, as well as some routes.

mod owner;mod pet;pub type Anchor = RouterAnchor<AppRoute>;struct FullStackApp {}pub enum Msg {}#[derive(Switch, Clone, Debug)]pub enum AppRoute { #[to = "/app/create-owner"] CreateOwner, #[to = "/app/create-pet/{id}"] CreatePet(i32), #[to = "/app/{id}"] Detail(i32), #[to = "/"] Home,}

Our app has routes for Home (e.g., list owners), for looking at an owner detail page and for creating owners and pets.

Then, we implement the Component trait for our FullStackApp so we can use it as an entry point.

impl Component for FullStackApp { type Message = Msg; type Properties = (); fn create(_: Self::Properties, _link: ComponentLink<Self>) -> Self { Self {} } fn update(&mut self, _msg: Self::Message) -> ShouldRender { true } fn change(&mut self, _props: Self::Properties) -> ShouldRender { true } fn view(&self) -> Html { html! { <div class=classes!("app")> <div class=classes!("nav")> <Anchor route=AppRoute::Home>{"Home"}</Anchor> </div> <div class=classes!("content")> <Router<AppRoute, ()> render = Router::render(move |switch: AppRoute| { match switch { AppRoute::CreateOwner => { html! { <div> <owner::create::CreateForm /> </div>} } AppRoute::CreatePet(owner_id) => { html! { <div> <pet::create::CreateForm owner_id=owner_id/> </div>} } AppRoute::Detail(owner_id) => { html! { <div> <owner::detail::Detail owner_id=owner_id/> </div>} } AppRoute::Home => { html! { <div> <owner::list::List /> <br /> <Anchor route=AppRoute::CreateOwner> { "Create New Owner" } </Anchor> </div> } } } }) /> </div> </div> } }}

Our root component doesn’t really do much; it just contains a simple menu with a Home link, which is always visible, and then includes the router, which, for each of our routes, configures which component should be shown and what is just extra markup.

For example, for AppRoute::Home, our default Home route, we show a list of owners and a link to the Create New Owner form.

Finally, we need the following snippet to make the Wasm-magic work and so we get an actual web app out of trunk:

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

List of owners

Let’s start with the owner list shown on Home because it’s the simplest component.

In the owner module, we create a mod.rs file, a create.rs , a detail.rs and a list.rs file.

In mod.rs, we simply export these modules:

pub mod create;pub mod detail;pub mod list;

Then, we start implementing list.rs.

The goal is to fetch the list of owners from the backend and to display each owner linking to its detail page.

We first define the List struct, which is the base of our component:

pub struct List { fetch_task: Option<FetchTask>, owners: Option<Vec<OwnerResponse>>, link: ComponentLink<Self>,}

The ComponentLink is Yew’s method of sending messages inside the component to, for example, trigger side effects such as a web request.

Because we’re using Yew’s FetchService, we also need to save the fetch_task we’re going to use to fetch the owners from the backend.

The list of owners is None at first and will be filled once the request to the backend (hopefully) returns a list of owners.

Then, we define our Msg enum, defining the messages that are handled by the component.

pub enum Msg { MakeReq, Resp(Result<Vec<OwnerResponse>, anyhow::Error>),}

We simply create one action to make the request and one to receive the result from the backend.

With that, we can implement the Component as follows:

impl Component for List { type Properties = (); type Message = Msg; fn create(_props: Self::Properties, link: ComponentLink<Self>) -> Self { link.send_message(Msg::MakeReq); Self { fetch_task: None, link, owners: None, } } fn view(&self) -> Html { html! { <div> { self.render_list() } </div> } } fn update(&mut self, msg: Self::Message) -> ShouldRender { match msg { Msg::MakeReq => { self.owners = None; let req = Request::get("http://localhost:8000/owner") .body(Nothing) .expect("can make req to backend"); let cb = self.link.callback( |response: Response<Json<Result<Vec<OwnerResponse>, anyhow::Error>>>| { let Json(data) = response.into_body(); Msg::Resp(data) }, ); let task = FetchService::fetch(req, cb).expect("can create task"); self.fetch_task = Some(task); () } Msg::Resp(resp) => { if let Ok(data) = resp { self.owners = Some(data); } } } true } fn change(&mut self, _props: Self::Properties) -> ShouldRender { true }}

When the component is created, we use the component link to trigger MakeReq, sending a request for the owners to the backend. Then we initialize the component.

In update, we handle the request and response messages, using the FetchService to send a request to http://localhost:8000/owner, where our backend serves us the list of owners.

Then we parse the response in the callback and call Msg::Resp(data), which, if no error occurred, will set the data into our component.

In the render function, we simply call render_list, which we implement on List itself as follows:

impl List { fn render_list(&self) -> Html { if let Some(t) = &self.owners { html! { <div class=classes!("list")> { t.iter().map(|name| self.view_owner(name)).collect::<Html>() } </div> } } else { html! { <div class=classes!("loading")>{"loading..."}</div> } } } fn view_owner(&self, owner: &OwnerResponse) -> Html { html! { <div class=classes!("list-item")> <Anchor route=AppRoute::Detail(owner.id as i32)> { &owner.name } </Anchor> </div> } }}

Basically, if we have self.owners set, we iterate over the list and render view_owner for each of them. This creates a link to AppRoute::Detail with the owner’s ID, which is a link to the detail page.

If we don’t have data, we show a loading… message.

That’s it for listing owners. Let’s continue with the detail page in detail.rs.

Creating a detail page for owners

The owner detail page is a bit tricker. Here, we need to make two requests: one to fetch the owner with the given owner ID (so we can also refresh the page and use the route directly), as well as the list of pets for the owner. Also, we have to implement the functionality for deleting pets here.

The general idea is the same:

#[derive(Properties, Clone, PartialEq)]pub struct Props { pub owner_id: i32,}pub struct Detail { props: Props, link: ComponentLink<Self>, pets: Option<Vec<PetResponse>>, owner: Option<OwnerResponse>, fetch_pets_task: Option<FetchTask>, fetch_owner_task: Option<FetchTask>, delete_pet_task: Option<FetchTask>,}pub enum Msg { MakePetsReq(i32), MakeOwnerReq(i32), MakeDeletePetReq(i32, i32), RespPets(Result<Vec<PetResponse>, anyhow::Error>), RespOwner(Result<OwnerResponse, anyhow::Error>), RespDeletePet(Response<Json<Result<(), anyhow::Error>>>, i32),}

We define the props for the component with which it’s called — in this case, the owner id from the route path.

Then, we define the Detail struct, which holds our component’s data, including the pets and owner we’re going to fetch, as well as the component link and the props and FetchTasks for fetching pets, fetching an owner, and deleting a pet.

Let’s take a look at the component implementation:

impl Component for Detail { type Properties = Props; type Message = Msg; fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { link.send_message(Msg::MakePetsReq(props.owner_id)); link.send_message(Msg::MakeOwnerReq(props.owner_id)); Self { props, link, owner: None, pets: None, fetch_pets_task: None, fetch_owner_task: None, delete_pet_task: None, } } fn view(&self) -> Html { html! { <div> { self.render_detail(&self.owner, &self.pets)} </div> } } fn update(&mut self, msg: Self::Message) -> ShouldRender { match msg { Msg::MakePetsReq(id) => { let req = Request::get(&format!("http://localhost:8000/owner/{}/pet", id)) .body(Nothing) .expect("can make req to backend"); let cb = self.link.callback( |response: Response<Json<Result<Vec<PetResponse>, anyhow::Error>>>| { let Json(data) = response.into_body(); Msg::RespPets(data) }, ); let task = FetchService::fetch(req, cb).expect("can create task"); self.fetch_pets_task = Some(task); () } Msg::MakeOwnerReq(id) => { let req = Request::get(&format!("http://localhost:8000/owner/{}", id)) .body(Nothing) .expect("can make req to backend"); let cb = self.link.callback( |response: Response<Json<Result<OwnerResponse, anyhow::Error>>>| { let Json(data) = response.into_body(); Msg::RespOwner(data) }, ); let task = FetchService::fetch(req, cb).expect("can create task"); self.fetch_owner_task = Some(task); () } Msg::MakeDeletePetReq(owner_id, pet_id) => { let req = Request::delete(&format!( "http://localhost:8000/owner/{}/pet/{}", owner_id, pet_id )) .body(Nothing) .expect("can make req to backend"); let cb = self.link.callback( move |response: Response<Json<Result<(), anyhow::Error>>>| { Msg::RespDeletePet(response, pet_id) }, ); let task = FetchService::fetch(req, cb).expect("can create task"); self.delete_pet_task = Some(task); () } Msg::RespPets(resp) => { if let Ok(data) = resp { self.pets = Some(data); } } Msg::RespOwner(resp) => { if let Ok(data) = resp { self.owner = Some(data); } } Msg::RespDeletePet(resp, id) => { if resp.status().is_success() { self.pets = self .pets .as_ref() .map(|pets| pets.into_iter().filter(|p| p.id != id).cloned().collect()); } } } true } fn change(&mut self, props: Self::Properties) -> ShouldRender { self.props = props; true }}

The basics are the same, our view calls a render_detail function, which we’ll look at in a bit and in create, we also initialize our component and trigger the fetching of pets and the owner by sending the corresponding messages and the given owner_id.

In update, we need to implement the request and response handlers for fetching pets and the owner. These are almost exactly the same as in the List component, just with different URLs and different return types.

In the handler of MakeDeletePetReq, we send the DELETE request using the owner_id and pet_id given. If it works, we trigger the Msg::RespDeletePet message.

There, if the request succeeds, we simply remove the pet with the given ID from our list of pets. This is nice because it means we don’t need to refetch the whole list of pets.

Let’s look at the rendering code for the owner detail:

impl Detail { fn render_detail( &self, owner: &Option<OwnerResponse>, pets: &Option<Vec<PetResponse>>, ) -> Html { match owner { Some(o) => { html! { <div class=classes!("detail")> <h1>{&o.name}{" ("}<span class=classes!("id")>{o.id}</span>{")"}</h1> { self.view_pet_list(pets) } <br /> <Anchor route=AppRoute::CreatePet(o.id as i32)> { "Create New Pet" } </Anchor> </div> } } None => { html! { <div class=classes!("loading")>{"loading..."}</div> } } } } fn view_pet_list(&self, pets: &Option<Vec<PetResponse>>) -> Html { match pets { Some(p) => { html! { p.iter().map(|pet| self.view_pet(pet)).collect::<Html>() } } None => { html! { <div class=classes!("loading")>{"loading..."}</div> } } } } fn view_pet(&self, pet: &PetResponse) -> Html { let id = pet.id; let owner_id = self.props.owner_id; html! { <div class=classes!("list-item", "pet")> <div><b>{ &pet.name }</b> { " (" } <button onclick=self.link.callback(move |_| Msg::MakeDeletePetReq(owner_id, id))>{"Delete"}</button> {")"}</div> <div>{ &pet.animal_type }</div> <div>{ &pet.color.as_ref().unwrap_or(&String::new()) }</div> </div> } }}

Again, if we have data, we render it. Otherwise, we show a loading… indicator. Once we have our owner, we render its name with its ID next to it.

Below, we render the list of pets, with the actual rendering of the pets in view_pet. We also create the button for deleting pets, which has an onclick handler triggering the MsgMakeDeletePetReq message.

Below the pet list, we show a link to the Create Pet route.

We’re almost done. Now we just have to look at the components for creating owners and pets. Let’s start with owners in create.rs:

pub struct CreateForm { link: ComponentLink<Self>, fetch_task: Option<FetchTask>, state_name: String,}pub enum Msg { MakeReq, Resp(Result<OwnerResponse, anyhow::Error>), EditName(String),}

Again, we start with the Component struct and the Msg enum.

In this case, we need the data infrastructure to make a request to create an owner, but we also need a way to create and edit a form.

For this purpose, we create the state_name field on our component and the EditName(String) in Msg.

Let’s look at the Component implementation next:

impl Component for CreateForm { type Properties = (); type Message = Msg; fn create(_props: Self::Properties, link: ComponentLink<Self>) -> Self { Self { link, state_name: String::new(), fetch_task: None, } } fn view(&self) -> Html { html! { <div> { self.render_form() } </div> } } fn update(&mut self, msg: Self::Message) -> ShouldRender { match msg { Msg::MakeReq => { let body = OwnerRequest { name: self.state_name.clone(), }; let req = Request::post("http://localhost:8000/owner") .header("Content-Type", "application/json") .body(Json(&body)) .expect("can make req to backend"); let cb = self.link.callback( |response: Response<Json<Result<OwnerResponse, anyhow::Error>>>| { let Json(data) = response.into_body(); Msg::Resp(data) }, ); let task = FetchService::fetch(req, cb).expect("can create task"); self.fetch_task = Some(task); () } Msg::Resp(resp) => { ConsoleService::info(&format!("owner created: {:?}", resp)); if let Ok(_) = resp { RouteAgent::dispatcher().send(RouteRequest::ChangeRoute(Route { route: "/".to_string(), state: (), })); } } Msg::EditName(input) => { self.state_name = input; } } true } fn change(&mut self, _props: Self::Properties) -> ShouldRender { true }}impl CreateForm { fn render_form(&self) -> Html { let edit_name = self .link .callback(move |e: InputData| Msg::EditName(e.value)); html! { <div class=classes!("pet-form")> <div> <input type="text" value={self.state_name.clone()} oninput={edit_name} /> </div> <div> <button onclick=self.link.callback(move |_| Msg::MakeReq)>{"Submit"}</button> </div> </div> } }}

As you can see, in render_form inside the CreateForm implementation, we create a simple form input field, which takes self.state_name as a value. This means it’s directly connected to our state.

We use the oninput event handler to, each time someone writes text into the input field, call the Msg::EditName message with the value of the input field.

When you look at the update function in the Component implementation, the handler for Msg::EditName simply sets self.state_name to the given value in the input. This ensures that we always have the value that’s in the form field inside our component.

This is important once we click on the Submit button, which triggers Msg::MakeReq. There, we create a JSON payload for creating an owner using self.state_name as the value for the name.

Then we send this payload to the backend endpoint for creating an owner and, if everything is successful, use yew_router‘s RouteAgent and dispatcher to manually change the route back to "/", our Home route.

Detail page for pets

That’s how easy form handling is with Yew! Let’s look at the final part of the puzzle by creating a pet module with a mod.rs and create.rs inside.

In the mod.rs, we again just export create:

pub mod create;

In create.rs, we implement the component for adding new pets, which will be very similar to the owner’s CreateForm we just implemented.

#[derive(Properties, Clone, PartialEq)]pub struct Props { pub owner_id: i32,}pub struct CreateForm { props: Props, link: ComponentLink<Self>, fetch_task: Option<FetchTask>, state_pet_name: String, state_animal_type: String, state_color: Option<String>,}pub enum Msg { MakeReq(i32), Resp(Result<PetResponse, anyhow::Error>), EditName(String), EditAnimalType(String), EditColor(String),}

The CreatePet form takes as a prop the owner_id of the owner for whom we want to create a pet.

Then, we define state_pet_name, state_animal_type, and state_color to keep the state of our three form fields, same as we did for the owner.

For Msg, it’s the same: we need handlers for each of our form fields, as well as for making the create pet request and handling its response.

Let’s look at the implementation of Component and the rendering logic:

impl Component for CreateForm { type Properties = Props; type Message = Msg; fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { Self { props, link, state_pet_name: String::new(), state_animal_type: String::from("cat"), state_color: Some(String::from("black")), fetch_task: None, } } fn view(&self) -> Html { html! { <div> { self.render_form(self.props.owner_id) } </div> } } fn update(&mut self, msg: Self::Message) -> ShouldRender { match msg { Msg::MakeReq(id) => { let body = PetRequest { name: self.state_pet_name.clone(), animal_type: self.state_animal_type.clone(), color: self.state_color.clone(), }; let req = Request::post(&format!("http://localhost:8000/owner/{}/pet", id)) .header("Content-Type", "application/json") .body(Json(&body)) .expect("can make req to backend"); let cb = self.link.callback( |response: Response<Json<Result<PetResponse, anyhow::Error>>>| { let Json(data) = response.into_body(); Msg::Resp(data) }, ); let task = FetchService::fetch(req, cb).expect("can create task"); self.fetch_task = Some(task); () } Msg::Resp(resp) => { ConsoleService::info(&format!("pet created: {:?}", resp)); if let Ok(_) = resp { RouteAgent::dispatcher().send(RouteRequest::ChangeRoute(Route { route: format!("/app/{}", self.props.owner_id), state: (), })); } } Msg::EditName(input) => { self.state_pet_name = input; } Msg::EditAnimalType(input) => { ConsoleService::info(&format!("input: {:?}", input)); self.state_animal_type = input; } Msg::EditColor(input) => { self.state_color = Some(input); } } true } fn change(&mut self, props: Self::Properties) -> ShouldRender { self.props = props; true }}impl CreateForm { fn render_form(&self, owner_id: i32) -> Html { let edit_name = self .link .callback(move |e: InputData| Msg::EditName(e.value)); let edit_animal_type = self.link.callback(move |e: ChangeData| match e { ChangeData::Select(elem) => Msg::EditAnimalType(elem.value()), _ => unreachable!("only used on select field"), }); let edit_color = self .link .callback(move |e: InputData| Msg::EditColor(e.value)); html! { <div class=classes!("pet-form")> <div> <input type="text" value={self.state_pet_name.clone()} oninput={edit_name} /> </div> <div> <select onchange={edit_animal_type}> <option value="cat" selected=true>{ "Cat" }</option> <option value="dog">{ "Dog" }</option> </select> </div> <div> <input type="text" value={self.state_color.clone()} oninput={edit_color} /> </div> <div> <button onclick=self.link.callback(move |_| Msg::MakeReq(owner_id))>{"Submit"}</button> </div> </div> } }}

Let’s start with the render_form function in CreateForm. Here, we again create input fields for all the pet’s fields. However, this time with a twist: we use a select field for the animal type, since we want to limit it to cats and dogs.

That means, for the callback handler of edit_animal_type, we get a ChangeData instead of an InputData. Inside it, we need to match on the type of change. We only want to react on ChangeData::Select(elem) and take the element’s value, sending it over to be set in our component state.

For the other two fields, the process is the same as in our Create Owner component.

In terms of the Component implementation, there isn’t really anything new here, either. We implement the handler for calling the create pet endpoint on our backend and the handlers for passing the form input field values to our state, so we can create the payload for this endpoint.

With this last component out of the way, our Rust full-stack web app implementation is complete! The only thing left is to test that it actually works.

Testing our Rust full-stack app

We can run both the frontend and the backend with a Postgres database running on port 7878 (navigate to http://localhost:8080).

There, we’re greeted by an empty Home screen. We can click Create New Owner, which shows us the form:

Full-stack Rust: A complete tutorial with examples - LogRocket Blog (4)

Submitting will create the owner, which we’ll see in the list back at Home:

Full-stack Rust: A complete tutorial with examples - LogRocket Blog (5)

Next, let’s click on the new owner to see the owner detail page:

Full-stack Rust: A complete tutorial with examples - LogRocket Blog (6)

Now we can start adding some pets using Create New Pet:

Full-stack Rust: A complete tutorial with examples - LogRocket Blog (7)

Once we’re done, we’re redirected back to the Owner Detailpage, which shows us our newly added list of pets:

Full-stack Rust: A complete tutorial with examples - LogRocket Blog (8)

Finally, we can try to delete a pet by clicking the Delete button next to it:

Full-stack Rust: A complete tutorial with examples - LogRocket Blog (9)

Fantastic; it works! And all of it written in Rust.

You can find the full code for this example on GitHub.

Conclusion

In this tutorial, we demonstrated how to build a simple full-stack web application fully in Rust. We covered how to create a multimodule workspace using Cargo and how to share code between the frontend and backend parts of the application.

Thus Rust web ecosystem is still maturing, so it’s quite impressive that you can already build modern full-stack web apps with too much fuzz.

I’m excited to see how the Wasm journey continues and I’m very much looking forward to seeing the Rust async web ecosystem develop even further, with improved stability, compatibility, and richness in libraries.

In any case, the future of web development in Rust looks promising!

LogRocket: Full visibility into web frontends for Rust apps

Debugging Rust applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking the performance of your Rust apps, automatically surfacing errors, and tracking slow network requests and load time, try LogRocket.

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Rust application. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.

Modernize how you debug your Rust apps — start monitoring for free.

Full-stack Rust: A complete tutorial with examples - LogRocket Blog (2024)

FAQs

Can we use Rust for web applications? ›

Safety and Security: Both Rust and WebAssembly prioritize secure execution, making your web applications more reliable. Ease of Integration: Rust has first-class support for WebAssembly, making it straightforward to compile Rust code to wasm and integrate it into your web applications.

Is rust a full stack language? ›

Rust. Rust is a modern and high-performance language for full-stack development, offering key features that prioritize safety, concurrency, and performance. Rust is the ninth-best language for full-stack web development due to its focus on memory safety, performance, and concurrency.

How do I run a full stack project? ›

Full stack development in an end-to-end workflow
  1. Planning and design. The full stack web developer starts by gathering requirements and understanding the project scope. ...
  2. Front end development. ...
  3. Back end development. ...
  4. Testing and integration. ...
  5. Deployment and maintenance.

Is Rust better than Python? ›

Rust and Python both have their strengths and are suitable for different parts of data and development projects. Python excels in rapid development, data analysis, and machine learning, while Rust offers performance, safety, and control, making it ideal for systems programming and performance-critical applications.

What is the most used Rust web framework? ›

Actix Web. Actix Web is easily the most popular web framework for Rust. It satisfies just about all the major needs: it's high-performance, supports a broad swath of server features, and requires little ceremony to put together a basic site.

Is Rust a dying language? ›

Rust is One of the Fastest Growing Programming Languages, According to The IEEE Spectrum Development report by Tiobe Co. There are 2.8 million coders writing in Rust, and companies from Microsoft to Amazon regard it as key to their future.

Which language is better than Rust? ›

While Rust is great for system programming, it offers less flexibility than Python. Documentation Due to its longevity, Python's documentation is extensive, user-friendly, and easier to understand. Rust's documentation is comprehensive but more technical and not user-friendly. Community Python has a larger community.

What language should you learn before Rust? ›

Python is generally recommended as the easier language to learn first, with a simpler development experience and smaller learning curve compared to Rust. However, Rust can be a valuable skill to add for systems programming, performance optimization, and concurrent applications.

What is a full stack developer's salary? ›

Full Stack Developer salary in India ranges between ₹ 1.8 Lakhs to ₹ 16.0 Lakhs with an average annual salary of ₹ 9.1 Lakhs. Salary estimates are based on 36k latest salaries received from Full Stack Developers. 0 - 6 years exp. 0 - 6 years exp.

Can you learn full stack on your own? ›

Short answer is yes, you can learn full stack web development by own. But I would suggest if you are a beginner then you enroll in a course because the course has a guide, road map and timeline which you will follow and complete it without losing focus.

Which language is best for full stack developers? ›

What Are The Programming Languages the Full Stack Developers Should Know?
  • 1) HTML, CSS and JavaScript: HTML and CSS are the fundamental languages for front-end browser that you should take the understanding of, right out of the gate. ...
  • 2) MEAN: ...
  • 3) Python: ...
  • 4) Swift IOS: ...
  • 5) Ruby on Rails:

Can I make a website with Rust? ›

However, Rust is a system programming language; web frameworks like Rocket, Actix, Warp, and more enable developers to create web applications with Rust. Combined, Rocket and Diesel provide a powerful and efficient toolset for building web apps in Rust.

Can Rust run on web? ›

If you have some Rust code, you can compile it into WebAssembly (Wasm). This tutorial will show you how to compile a Rust project into WebAssembly and use it in an existing web app.

Is Rust good for web APIs? ›

You can also use Rust to develop a web API, but Go shines brighter for this use case. Rust's focus on memory safety increases complexity and development time, especially for a fairly simple web API. You can leverage the larger amount of control you have over your code in other applications like game development.

Can Rust be used for frontend? ›

While it is definitely possible to write front end logic in Rust and then compile it to Web Assembly, I believe the best tool for the job of front end development is — for now at least — still javascript or typescript.

References

Top Articles
Latest Posts
Article information

Author: Jamar Nader

Last Updated:

Views: 6180

Rating: 4.4 / 5 (75 voted)

Reviews: 82% of readers found this page helpful

Author information

Name: Jamar Nader

Birthday: 1995-02-28

Address: Apt. 536 6162 Reichel Greens, Port Zackaryside, CT 22682-9804

Phone: +9958384818317

Job: IT Representative

Hobby: Scrapbooking, Hiking, Hunting, Kite flying, Blacksmithing, Video gaming, Foraging

Introduction: My name is Jamar Nader, I am a fine, shiny, colorful, bright, nice, perfect, curious person who loves writing and wants to share my knowledge and understanding with you.