DEV Community

Cover image for Building a Rust app with Perseus
Matt Angelosanto for LogRocket

Posted on • Originally published at blog.logrocket.com

Building a Rust app with Perseus

Written by Eze Sunday✏️

Rust is a popular system programming language, known for its robust memory safety features and exceptional performance. While Rust was originally a system programming language, its application has evolved. Now you can see Rust in different app platforms, mobile apps, and of course, in web apps — both in the frontend and backend, with frameworks like Rocket, Axum, and Actix making it even easier to build web applications with Rust.

Reactive web frameworks with server-side rendering (SSR), mostly JavaScript frameworks like Next.js, Nuxt.js, and Svelte, are gaining increasing popularity. Interestingly, developers from the Rust community are also building alternatives that leverage the safety and performance of both Rust and WebAssembly combined to build reactive frontends. Perseus is the newest Rust framework for building frontend components.

Perseus is a fast frontend web development framework for Rust with built-in support for reactivity using Sycamore, server-side rendering, and much more. Sycamore is a frontend library that allows you to build interactive user interfaces with Rust. I’d say that Perseus is to Sycamore as Next.js is to React, so it’ll be helpful for you to have a fair understanding of Sycamore before jumping into using Perseus — although it’s not necessary to follow along in this article.

In this article, we’ll learn how to build a Rust web application with Perseus and how to deploy it to your server. The project we’ll build is a to-do application that allows the user to add and delete tasks. Here is what the finished project will look like: Reactive To-do App Demo Jump ahead:

Prerequisites

To follow along with this tutorial, you should:

  • Have a basic understanding of Rust
  • Have Rust installed on your computer. In case you don’t have Rust installed, please see the Rust installation guide

Setting up our Perseus environment

Before you can start building your Rust application with Perseus, you'll need to set up your environment. This involves installing the Perseus command-line interface (CLI) and initializing your Perseus app. Let's walk through the installation process step by step.

Installing the Perseus CLI

To begin, install the Perseus CLI, which allows you to manage your Perseus application seamlessly. Open your terminal and run the following command:

cargo install perseus-cli
Enter fullscreen mode Exit fullscreen mode

This command will download and install the Perseus CLI on your system and make it available globally. To confirm that your installation went well, run perseus --version; the response should be perseus-cli 0.4.2.

Initializing your Perseus app

Once the installation is complete, navigate to the directory where you want your Perseus application to reside. From there, initialize your app by running the following code:

perseus init my-todo-app
Enter fullscreen mode Exit fullscreen mode

This will scaffold a Perseus boilerplate code that will serve as a starting point for your application. Think of it as the equivalent of create-react-app in React.js. The initialization process might take some time, depending on your network strength, and, once it's done, you can proceed to preview your setup by running:

perseus serve -w
Enter fullscreen mode Exit fullscreen mode

Notice the -w flag. It’s optional. While perseus serve runs your code, the -w flag watches your code changes and updates the UI accordingly. When the app build is complete, navigate to http://localhost:8080 to see the demo welcome to Perseus app: Perseus Homepage

At this point, if you open the Cargo.toml file, you’ll see everything that’s been set up for you to ensure that Perseus works as expected. It should look like this:

# Cargo.toml
[package]
name = "simple-todo-app"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

# Dependencies for the engine and the browser go here
[dependencies]
perseus = { version = "=0.4.2", features = [ "hydrate" ] }
sycamore = "^0.8.1"
serde = { version = "1", features = [ "derive" ] }
serde_json = "1"

# Engine-only dependencies go here
[target.'cfg(engine)'.dependencies]
tokio = { version = "1", features = [ "macros", "rt", "rt-multi-thread" ] }
perseus-axum = { version = "=0.4.2", features = [ "dflt-server" ] }

# Browser-only dependencies go here
[target.'cfg(client)'.dependencies]
Enter fullscreen mode Exit fullscreen mode

From the Cargo.toml file above, we can see that the Perseus version at the time of publication is 0.4.2 and has the following dependencies that are common to both the engine side (server-side) and client side of a Perseus application: sycamore, serde, and serde_json.

Perseus applications also make use of tokio, a Rust asynchronous runtime, and perseus-axum — an integration that streamlines the use of the Perseus frontend framework with the Axum web framework, specifically on the engine side. The configuration file also shows a section for browser-only dependencies. Even though there are no default dependencies in this section, it indicates a place where such dependencies could be added as your application evolves.

Now that the app setup is complete, let’s tour the app structure we just created and start building.

Exploring our Perseus app structure

Our app structure will look like this at the beginning:

├── Cargo.lock
├── Cargo.toml
└── src
    ├── main.rs
    └── templates
        ├── index.rs
        └── mod.rs
Enter fullscreen mode Exit fullscreen mode

src/main.rs is the natural main entry point of the app and it contains a single function, the main function that returns the PerseusApp:

mod templates;
use perseus::prelude::*;

#[perseus::main(perseus_axum::dflt_server)]
pub fn main<G: Html>() -> PerseusApp<G> {
    PerseusApp::new()
        .template(crate::templates::index::get_template())
}
Enter fullscreen mode Exit fullscreen mode

Notice that the main function has an attribute: [perseus::main(perseus_axum::dflt_server)]. Attributes are metadata applied to modules, crates, structs, or functions to enhance their functionality.

In the case of the Perseus main function attribute, the attribute essentially makes the main function the entry point of the Perseus app. The attribute takes perseus_axum::dflt_server as an argument, which sets up an Axum web server for your application and yes, Perseus uses the Axum server by default to set up the Perseus engine.

Additionally, the main function returns a PerseusApp, which is like the root node for all the templates in your Perseus application. Now, let’s create some views.

App templates with Perseus

In a Perseus application, the template directory is essentially where all your app views are located by default. Each file in the template directory represents a page and returns a Perseus template.

For instance, let's consider the following code:

pub fn get_template<G: Html>() -> Template<G> {
    Template::build("index").view(index_page).head(head).build()
}
Enter fullscreen mode Exit fullscreen mode

Here, we're creating a template for the homepage (or index) of our web app. And then we go ahead and assign the index_page (a view function) as the view we want to display. We’ll then pass the template to the Perseus app in the main function as we’ve seen earlier, like so:

mod templates;
PerseusApp::new().template(crate::templates::index::get_template())
Enter fullscreen mode Exit fullscreen mode

For most of it, in this tutorial, we’ll write our code in the index.rs template file. Let’s get into defining the state of the application.

Defining Perseus state

In a Perseus application, a "state" refers to the data that a view or template depends on to render correctly. The state comprises different data types defined in a struct. And it’s crucial to the reactivity of the app as the application updates each time there is a change in the state.

Let’s define a TodoListState. This state will serve as a data store for all the todos we’ll create:

use perseus::prelude::*;
use serde::{Deserialize, Serialize};
use std::rc::Rc;
use sycamore::prelude::*;
use web_sys::Event;

#[derive(Serialize, Deserialize, ReactiveState, Clone)]
#[rx(alias = "TodoListStateRx")]
struct TodoListState {
    todos: Vec<String>,
    new_todo: String,
}
Enter fullscreen mode Exit fullscreen mode

There is a lot of abstraction in this code, so let’s break it down.

In this TodoListState struct, we have two parts: todos and new_todo. The todos part is a list of your tasks — a vector of strings. The new_todo is also a string, and it's used to hold the new task that you want to add to the vector of strings (todos).

Now, let's talk about those attributes applied to the struct:

  • Serialize and Deserialize: These mean that your TodoListState can be turned/serialized into a format like JSON and also be converted back into the structure that your program can understand (that's Deserialize). Remember the Serde library we added to the dependency list initially? This is exactly where we use it
  • ReactiveState: This is a custom derive attribute specific to Perseus. It tells the program that if anything in TodoListState changes, like adding a new task, Perseus should automatically update the parts of the app that display this information
  • Clone: The Clone trait in Rust allows for the duplication of an object. This simply means that the TodoListState can be duplicated, or cloned. So, you can create an exact copy of your task list if you want to
  • #[rx(alias = "TodoListStateRx")]: This is a custom attribute related to the reactive system of Perseus, which is built on Sycamore. This attribute allows you to provide an alias for the reactive version of the state. In this case, the reactive version of TodoListState can be referred to as TodoListStateRx. We’ll assign it to functions that contain reactive state code

Also, on the engine side, we initialize the state with some data as shown below:

#[engine_only_fn]
async fn get_build_state(_info: StateGeneratorInfo<()>) -> TodoListState {
    TodoListState {
        todos: vec!["Grocery shopping".to_string(), "Exercise for 30 minutes every morning".to_string()],
        new_todo: "".to_string(),
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, let’s add a heading and the form that we’ll use to add a new to-do to the list of to-dos:

fn header<G: Html>(cx: Scope) -> View<G> {
    view! { cx,
        h1 { "A simple Reactive Todo App" }
    }
}

#[auto_scope]
fn todo_form<G: Html>(cx: Scope, state: &TodoListStateRx) -> View<G> {
    let new_todo_form = new_todo(cx.clone(), state);
    view! { cx,
        div {
            (new_todo_form)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Let’s demystify each of the functions in the code above; we’ll start with the header function:

fn header<G: Html>(cx: Scope) -> View<G> {
    view! { cx,
        h1 { "A simple Reactive Todo App" }
    }
}
Enter fullscreen mode Exit fullscreen mode

The header view function takes a scope, shorthand as cx; this convention is borrowed from Sycamore and is a requirement for every view function we pass to Sycamore. For simplicity, Scope is used to track any reactive variables or computations within a view, so it can automatically update the view whenever those values change.

Creating a to-do form

In the code above, we leverage the view! macro to return the view that will be rendered. In the view, we pass an h1 element with its contents in the curly braces. This is a pretty simple view function, so let’s take a look at the todo_form view function, which is a bit more complex:

#[auto_scope]
fn todo_form<G: Html>(cx: Scope, state: &TodoListStateRx) -> View<G> {
    let new_todo_form = new_todo(cx.clone(), state);
    view! { cx,
        div {
            (new_todo_form)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice that beyond the Scope argument, the todo_form view accepts a state as the second argument. Now, that’s a little complicated and Perseus needs to do a lot more work on this new view in the background. Therefore, we use the #[auto_scope] to abstract and simplify it.

Essentially, if not for the #[auto_scope] attribute, the todo_form function argument would look like this:

fn todo_form<'page, G: Html>(cx: BoundedScope<'_, 'page>, state: &'page &TodoListStateRx) -> View<G> 
Enter fullscreen mode Exit fullscreen mode

Thankfully, we don’t have to write that anymore, thanks to #[auto_scope].

So, in the todo_form view, we are calling the new_todo view and passing the state to it. It then does its magic and displays it in the form inside a div element as shown below:

let new_todo_form = new_todo(cx.clone(), state);
view! { cx,
    div {
        (new_todo_form) 
    }
}
Enter fullscreen mode Exit fullscreen mode

Adding a new task to the to-do list

Now that we have a form to create a to-do, let’s add the to-do to the list of to-dos in the state. First, let’s create the view that adds an item to the list of to-dos. Add the piece of code below in your src/templates/index.rs file:

#[auto_scope]
fn new_todo<G: Html>(cx: Scope, state: &TodoListStateRx) -> View<G> {
    view! {cx,
        form(on:submit = move |e: Event| {
            e.prevent_default();
            let new_todo: Rc<String> = state.new_todo.get().clone();

            if !new_todo.is_empty() {
                let new_todo_str: String = (*new_todo).clone();
                let mut todos = state.todos.get().as_ref().clone();
                todos.push(new_todo_str);
                state.todos.set(todos.to_vec());
                state.new_todo.set(String::new());
            }
        }) {
            input(id = "todo-input", type = "text", bind:value = state.new_todo)
            button(id = "todo-button") { "Add Item" }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In the above code, the new_todo function creates a form view for a new to-do item where the user can input a new to-do. When the form is submitted, it adds the new to-do to the list and clears the input field.

Let’s take a quick overview of some key parts of the function:

  1. let new_todo_str: String = (*new_todo).clone();: This part of the code clones the new to-do the user just typed so it can be added to the to-do list
  2. let mut todo: Vec<String> = state.todos.get().as_ref().clone();: This part also retrieves the current to-do list from the state. But what is actually going on here?state.todos.get() is of type Rc<T>, reference counting; this type allows multiple owners of the same data, which is great for shared data in memory, but it doesn't allow you to directly clone the inner data. That’s why we introduce the .as_ref() method to get a reference to the value inside the Rc<T>, which is a type of Vec<String>. Then, we clone it with the .clone() method
  3. todos.push(new_todo_str);: Here, we add the new to-do to the to-do list
  4. state.todos.set(todos.to_vec());: This updates the to-do list in the state. Essentially, we replaced the entire existing todos in the state with the updated todos
  5. state.new_todo.set(String::new());: Finally, we reset the new to-do string in the state

Editing our to-do list functionality

As we described in the previous section, we want to add the new to-dos to the list, display them, and also give the user a chance to delete the to-do. Here is the function for it:

#[auto_scope]
fn todo_list<G: Html>(cx: Scope, state: &TodoListStateRx) -> View<G> {
    view! { cx,
        div(id = "todo-items") {
            ul {
                (View::new_fragment(
                    state.todos.get()
                        .iter()
                        .rev()
                        .enumerate()
                        .map(|(index, item)| {
                            let item = item.clone();
                            view! { cx,
                                li(class = "todo-item") {
                                    (item)
                                    button( class = "remove-button", on:click = move |_| {
                                        let mut todos = state.todos.get().as_ref().clone();
                                        todos.retain(|todo| *todo != item);
                                        state.todos.set(todos.to_vec());
                                    }) { "x" }
                                }
                            }
                        })
                        .collect(),
                ))
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Basically, this view function takes in the state of the to-do list, and it generates a dynamic, interactive HTML list of to-do items. Each item comes with a button that allows for the removal of the item from the list.

Let’s examine the code:

  1. (View::new_fragment(: This creates a new DocumentFragment, which is a set of HTML elements without a parent element. It’s used as a temporary container to wrap and manipulate DOM nodes. It's used here to create a list of li elements, one for each to-do item
  2. state.todos.get().iter().rev().enumerate().map(|(index, item)| {: This line is iterating over the to-dos in the state, in reverse order so the most recent to-do appears first, and for each to-do item, it creates a new li element
  3. button( class = "remove-button", on:click = move |_| {: This creates a button for each to-do item, with an attached click event handler. When this button is clicked, it will trigger the code that removes the item from the list and resets the to-do list in this part of the code:

    let mut todos = state.todos.get().asref().clone();
    todos.retain(|todo| *todo != item); // remove the todo
    state.todos.set(todos.tovec());```
    {% endraw %}
    

Adding a list view to our to-do app

Now, we can now take all the views we’ve created and put them together in a {% raw %}todo_list_view view function:

#[auto_scope]
fn todo_list_view<G: Html>(cx: Scope, state: &TodoListStateRx) -> View<G> {
    view! { cx,
        div(id = "todo-container") {
            (header(cx.clone()))
            (todo_form(cx.clone(), state))
            (todo_list(cx, state))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, we can add the todo_list_view to the main template that we’ll pass to the Perseus app. Here is how the code will look:

#[engine_only_fn]
fn head(cx: Scope) -> View<SsrNode> {
    view! { cx,
        title { "Test App" }
        link(rel="stylesheet", href=".perseus/static/styles.css") {}
    }
}
pub fn get_template<G: Html>() -> Template<G> {
    Template::build("index")
        .build_state_fn(get_build_state)
        .view_with_state(todo_list_view)
        .head(head)
        .build()
}
Enter fullscreen mode Exit fullscreen mode

Notice the head function? That’s like your regular HTML <head>. That’s where you define everything you want to be in the head. In our case, I am only linking the CSS file to it.

Make sure you put the CSS file in the static directory at the root of your application, not in .perseus/static/styles.css but in static/style.css. Perseus will do the rest. Note that the static directory might not be there at first; you’ll have to create it. For this project, you can get all the source code, including the CSS, from GitHub.

If you got to this point, congratulations! 🙏

Go ahead and run the app by running perseus serve--w to ensure that our implementation works as expected at this point. Finally, let’s proceed to prepare the app for deployment.

Deploying your Perseus app

Deploying a Perseus app is easy and straightforward. You just need to run the command perseus deploy, and wait for a couple of minutes. After the build process is complete, you can now deploy the build you’ll find in the /pkg directory to any server.

But wait — Perseus doesn’t allow you to deploy the default error pages to production. You must create your own custom error views. So, create an additional view in the template directory src/error.rs to handle all the possible errors that could happen. You can customize it as you want and add this error view template to it:

use perseus::errors::ClientError;
use perseus::prelude::*;
use sycamore::prelude::*;

pub fn get_error_views<G: Html>() -> ErrorViews<G> {
    ErrorViews::new(|cx, err, _err_info, _err_pos| {
        match err {
            ClientError::ServerError { status, message: _ } => match status {
                404 => (
                    view! { cx,
                        title { "Page not found" }
                    },
                    view! { cx,
                        p { "Page now found" }
                    },
                ),
                // 4xx is a client error
                _ if (400..500).contains(&status) => (
                    view! { cx,
                        title { "Error" }
                    },
                    view! { cx,
                        p { "bad request" }
                    },
                ),
                // 5xx is a server error
                _ => (
                    view! { cx,
                        title { "Error" }
                    },
                    view! { cx,
                        p { "Sorry, internal server error." }
                    },
                ),
            },
            ClientError::Panic(_) => (
                view! { cx,
                    title { "Critical error" }
                },
                view! { cx,
                    p { "Sorry, something went wrong" }
                },
            ),
            ClientError::FetchError(_) => (
                view! { cx,
                    title { "Error" }
                },
                view! { cx,
                    p { "Network error, ensur you have stable internet connection)" }
                },
            ),
            _ => (
                view! { cx,
                    title { "Error" }
                },
                view! { cx,
                    p { (format!("Internal server error: '{}'.", err)) }
                },
            ),
        }
    })
}
Enter fullscreen mode Exit fullscreen mode

The code above returns an error view depending on the type of error that occurs in the application. You can customize each error view as you see it. You can now add the error view to the main function using the error_views method, as shown below:

mod templates;
use perseus::prelude::*;

#[perseus::main(perseus_axum::dflt_server)]
pub fn main<G: Html>() -> PerseusApp<G> {
    PerseusApp::new()
        .template(crate::templates::index::get_template())
        .error_views(crate::templates::error::get_error_views())
}
Enter fullscreen mode Exit fullscreen mode

Once this step is complete, you can now run the perseus deploy command. Successful output will look like this: Perseus Deploy Command Your application is now ready for deployment! The distribution files are located in the /pkg directory. To launch the application in a production environment, you'll need to run the server executable found in the same directory.

If you're using a Unix-based system (like Linux or macOS), navigate to the directory in your terminal and run the server using the following commands:

cd /pkg
./server
Enter fullscreen mode Exit fullscreen mode

If you're on a Windows system, you'll also need to navigate to the pkg directory but the command to run the server will be slightly different:

cd /pkg
server.exe
Enter fullscreen mode Exit fullscreen mode

Your app should be live now on port 8080: Deploying Perseus App On Port 8080 The output should look like this: Output Of Our Todo List

Final words

Harnessing the power of Rust, Perseus brings a new dimension to the frontend, something we’ve seen mostly in JavaScript-dominated frameworks like Next, Nuxt.js, and Svelte. Perseus gives Rust developers a compelling option for building robust, safe, predictable, and performant applications.

The best part is that it doesn’t use a virtual DOM for its reactivity, which can lead to a significant increase in performance.  Perseus not only inherits some of the best aspects of existing web frameworks but also strives to surpass them. Perseus went stable on April 9, 2023 after one year of beta development -- this is just the beginning!

I hope what we’ve learned so far can help make your journey to learning and building applications with Perseus a little easier. For further reading, check out the Perseus documentation. Happy hacking!


LogRocket: Full visibility into web frontends for Rust apps

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

LogRocket Dashboard Free Trial Banner

LogRocket is like a DVR for web apps, recording literally everything that happens on your Rust app. 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.

Top comments (0)