DEV Community

Cover image for Using Rust and Leptos to build beautiful, declarative UIs
Matt Angelosanto for LogRocket

Posted on • Originally published at blog.logrocket.com

Using Rust and Leptos to build beautiful, declarative UIs

Written by Eze Sunday✏️

As Rust continues to grow rapidly, so does its ecosystem of tools. One relatively new tool in this ecosystem is Leptos, a modern, full-stack web framework for building declarative and fast UIs with Rust and WebAssembly (Wasm).

Leptos uses a fine-grained reactivity system to efficiently update the DOM directly without the overhead of a virtual DOM like React's. Additionally, its isomorphic design simplifies development by allowing you to build both server-side rendered (SSR) and client-side rendered (CSR) applications with a single codebase.

If you’re coming from the JavaScript world, Leptos is similar to SolidJS, but with the added benefits of Rust's type safety, performance, and security, along with the portability and performance of Wasm.

In this guide, we will explore how to build UIs with Leptos. We’ll create a demo to-do app to explore this web framework for Rust in detail — you can find the full source code on GitHub. Let’s start by setting up our development environment.

Setting up Leptos

Since Leptos is a Rust framework, we need to have Rust installed first. Install Rust with Rustup using the command below for Unix systems:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Enter fullscreen mode Exit fullscreen mode

If you’re on a Windows machine, you’ll need to install the Windows Rustup installer for your system architecture.

To verify that Rust is installed correctly, run the following command in your terminal:

rustc --version
Enter fullscreen mode Exit fullscreen mode

The expected output should look like this:

rustc 1.75.0-nightly (75b064d26 2023-11-01)
Enter fullscreen mode Exit fullscreen mode

Now that we have Rust installed, let's set up Leptos. There are two ways to do this, depending on whether you want to build a full-stack SSR app or a CSR app.

In this guide, we’ll focus on building a client-side rendered application with Leptos and Trunk, is a zero-config Wasm web application bundler for Rust. Install Trunk system-wide by running the following command:

cargo install trunk
Enter fullscreen mode Exit fullscreen mode

Next, initialize a Rust binary application with the command below:

cargo init todo-app
Enter fullscreen mode Exit fullscreen mode

Move into the new Rust application directory you just created and install Leptos as a dependency, with the CSR feature enabled:

cargo add leptos --features=csr
Enter fullscreen mode Exit fullscreen mode

Once that is complete, your app directory structure should look like this:

├── Cargo.lock
├── Cargo.toml
├── src
│ └── main.rs
Enter fullscreen mode Exit fullscreen mode

Next, create an index.html file in the root directory and add the following basic HTML structure to it because Trunk needs a single HTML file to facilitate all asset building and bundling:

<!DOCTYPE html>
<html>
  <meta charset="UTF-8">
  <head></head>
  <body></body>
</html>
Enter fullscreen mode Exit fullscreen mode

Before we continue, make sure you have the wasm32-unknown-unknown Rust compilation target installed. This target allows you to compile Wasm code that will run on different platforms, such as Chrome, Firefox, and Safari.

If you don't have this target installed, you can install it with the following command:

rustup target add wasm32-unknown-unknown
Enter fullscreen mode Exit fullscreen mode

Our setup is now complete. The project structure should look like this:

.
├── Cargo.lock
├── Cargo.toml
├── index.html
├── src
│   └── main.rs
Enter fullscreen mode Exit fullscreen mode

Next, let’s construct our first component.

Understanding the structure of a Leptos component

Components are the building blocks of Leptos applications. They’re reusable, composable, reactive, and named in PascalCase. Leptos components are like UI components in other frontend frameworks like React and SolidJS — there are building blocks for creating complex user interfaces.

Leptos components take props as arguments, which we can use to configure and render the component. They must also return a View, which represents the UI element that the component will render.

Here is an example of a Leptos component:

#[component]
fn Button(text: Text) -> impl IntoView {
    view!{
        <button>{text}</button>
    }
}
Enter fullscreen mode Exit fullscreen mode

In most cases, you’ll need to add the #[component] attribute to the component function. However, this is not necessary if you’re returning the view directly in a closure in the main function, as shown in the following example:

use leptos::*;

fn main() {
    mount_to_body(|| view! { <p>"Hello, todo!"</p> })
}
Enter fullscreen mode Exit fullscreen mode

Otherwise, we’ll have to mount the component like so:

fn main() {
    mount_to_body(Button);
}
Enter fullscreen mode Exit fullscreen mode

Now that we understand how components work in Leptos, let’s get started with our demo Rust app.

Building a to-do application with Leptos and Rust

Now that we have a good idea of how the component should be, let’s go ahead and build the components for our to-do application. We’ll need three components:

  • App — The entry component
  • InputTodo — To accept user inputs and add them to the TodoList
  • TodoList — Will render all the to-dos in the to-do list

The application will allow you to add new to-do items, view them in a list, and delete the to-dos like so: User Shown Interacting With To Do App Built With Leptos And Rust. App Has Magenta Background With Title Todo App At Top And Text Input Field Where User Is Shown Typing To Do Items. Items Are Added To List And User Is Shown Hovering Over Items To Reveal X Symbol. Clicking X Symbol Deletes To Do Item From List

The App component

The App component will be the root component that we’ll use to compose other components declaratively. It will contain the TodoInput and the TodoList components, and we’ll pass the todos props to it.

Now, copy the code below and add it to the main.rs file. This will be the starting point for the entire application:

#[component]
fn App() -> impl IntoView {
    let todos = create_signal(vec![]);
    view! {
        <div class="todo-app">
            <h1>"Todo App"</h1>
            <TodoInput initial_todos={todos} />
            <TodoList todos={todos} />
        </div>
    }
}

fn main() {
    leptos::mount_to_body(App);
}
Enter fullscreen mode Exit fullscreen mode

The code above might throw a lot of errors because we haven’t defined the TodoInput and TodoList components yet.

If you look carefully, you’ll notice the create_signal function at the beginning of the App function. Leptos uses signals to create and manage the app state, so be prepared to see this function a lot.

Signals are functions that we can call to get or set their associated component value. When a signal's value changes, all of its subscribers are notified and their associated components are updated. Essentially, signals are the heart of the reactivity system in Leptos:

let todos: (ReadSignal<Vec<TodoItem>>, WriteSignal<Vec<TodoItem>>) = create_signal(vec![]);
Enter fullscreen mode Exit fullscreen mode

The create_signal() function returns a getter ReadSignal<T> and setter WriteSignal<T> pair for a signal. The setter allows you to update the component's state and the getter allows you to read the state.

We also passed along the todos signal to the TodoInput and TodoList components as props. This means that the todos signal will be required as a prop when creating those components.

Still in that same code above, we've passed a vector of TodoItem structs. That means that the state will be a list of TodoItem structs. Of course, the state can be of any type, but we’re using a struct format because it allows us to store multiple items effectively.

So, let's define the TodoItem struct with an id and the content of the todo as shown below:

#[derive(Debug, PartialEq, Clone)]
struct TodoItem {
    id: u32,
    content: String,
}
Enter fullscreen mode Exit fullscreen mode

The TodoInput component

Next, let’s go ahead to create the TodoInput component. Copy the code below into your main.rs file:

#[component]
fn TodoInput(
    initial_todos: (ReadSignal<Vec<TodoItem>>, WriteSignal<Vec<TodoItem>>),
) -> impl IntoView {
    let (_, set_new_todo) = initial_todos;
    let (default_value, set_default_value) = create_signal("");
    }
}
Enter fullscreen mode Exit fullscreen mode

In the code above, the TodoInput component takes a todos props as an argument. This will allow us to update the to-do list when a user inputs some text and hits the Enter key. Next, we destructure the initial_todos and get the set_new_todo method, which allows us to update the state.

In addition to props, we can also create in-component signals to manage the state directly within the component.

For instance, we created a signal named default_input_value to control the default value of the input field. This signal enables us to clear the input field after adding a new to-do, ensuring that the input field is ready for the next item.

Next, let's create the input field and add some properties to it. Add the following code after the second let statement in the TodoInput component:

    view! {
        <input
        type="text"
        class= "new-todo"
        autofocus=true
        placeholder="Add todo"
        on:keydown= move |event| {
            if event.key() == "Enter" && !event_target_value(&event).is_empty() {
                let input_value = event_target_value(&event);
                let new_todo_item = TodoItem { id: new_todo_id(), content: input_value.clone() };
                set_new_todo.update(|todo| todo.push(new_todo_item));
                set_default_value.set("");
            }}
            prop:value=default_value
        />
Enter fullscreen mode Exit fullscreen mode

You can add virtually any valid HTML attribute to the input field, including events. Leptos uses a colon : as a separator for events, unlike vanilla HTML, which doesn’t have a separator. For instance, the onclick event in HTML becomes on:click in Leptos code.

In our case, we need to track when the on:keydown down event is fired and handle it. To get the value of a text input field, Leptos provides a special method event_target_value(&event); that allows you to get the value of the typed input:

let input_value = event_target_value(&event);
Enter fullscreen mode Exit fullscreen mode

Then, we want to update the state when the user hits the Enter button. Leptos provides two primary methods for updating the state and triggering reactivity:

  • Use the .update() method when you need to perform additional checks, manipulations, or calculations before updating the state. This method accepts a callback function that receives the current state value and allows you to modify it before the update is applied
  • Use the .set() method when you want to directly assign a new value to the state without any additional logic. This method is more concise and efficient for simple state updates

In our example code, we used the .update() method — set_new_todo.update — because we're dealing with an array of structs.

Adding an item to an array also requires the push() method, which involves more than just assigning a new value. The .update() method allows us to perform the push() operation within the callback function to ensure the array is updated correctly.

Finally, you can update the value of the field with the prop:value property. This is equivalent to the value property in vanilla HTML.

Now, we have a TodoInput component fully set up. When we run the application, the output should look like so: Plain White To Do App Shown With Black Text Title Todo App Above Blue Outlined Text Input Component Containing Grey Text Directing User To Add Todo

The TodoList component

Next, we'll construct the TodoList component, which is responsible for rendering the entire TodoList element. Leptos offers two distinct approaches for declaratively rendering lists of items: static rendering and dynamic rendering.

Static list rendering involves rendering a list of items from a Vec<T>. This method is suitable for scenarios where the list items are fixed and known beforehand. Here’s an example:

let todos = vec!["Eat Dinner", "Eat Breakfast", "Prepare lunch"];
view! {
  <p>{values.clone()}</p>
  <ul>
      {values.into_iter()
          .map(|n| view! { <li>{n}</li>})
          .collect::<Vec<_>>()}
  </ul>
}
Enter fullscreen mode Exit fullscreen mode

Considering the code above, all it takes to render the static list is to iterate over it and return it in a view using the view! macro. In comparison, dynamic list rendering is specifically designed to be reactive and utilize Leptos' signal system. For this purpose, Leptos provides a dedicated <For/> component.

Given this information, you’ve probably guessed that we’ll be using dynamic list rendering for our TodoList component. When we add a new to-do item, we need the list to be updated and re-rendered.

The code below represents how our TodoList component will look. Copy and append it into your main.rs file:

#[component]
fn TodoList(todos: (ReadSignal<Vec<TodoItem>>, WriteSignal<Vec<TodoItem>>)) -> impl IntoView {
    let (todo_list_state, set_todo_list_state) = todos;
    let my_todos = move || {
        todo_list_state
            .get()
            .iter()
            .map(|item| (item.id, item.clone()))
            .collect::<Vec<_>>()
    };
    view! {
        <ul class="todo-list">
        <For
            each=my_todos
            key=|todo_key| todo_key.0
            children=move |item| {
                view! {
                    <li class="new-todo" > {item.1.content}
                        <button
                        class="remove"
                            on:click=move |_| {
                                set_todo_list_state.update(|todos| {
                                    todos.retain(|todo| &todo.id != &item.1.id)
                                });
                            }
                        >
                        </button>
                    </li>
                }
            }
        />
    </ul>
    }
}
Enter fullscreen mode Exit fullscreen mode

Let’s examine the code above. The <For/> component has three important attributes:

  • each: Takes any function that returns an iterator, typically a signal or derived signal. If the iterator is not reactive, simply use the static rendering method instead of using the <For/> component
  • key: Specifies a unique and stable key for each row in the list. In the <For /> component, we are just reading the id we already created initially when we added the to-do item to the list
  • children: Receives each item from the each iterator and returns a view. This is where you define the HTML markup for each item in the list. In our to-do list code, we render each to-do item's content and provide a remove button that triggers the deletion of the corresponding to-do item

It’s advisable to use unique ids for component keys. You could use the rand library function to generate random ids. Here’s an example function that uses the rand library to generate a new id and passes it to the function that creates new to-dos:

fn new_todo_id() -> u32 {
    let mut rng = rand::thread_rng();
    rng.gen()
}

let new_todo_item = TodoItem { id: new_todo_id(), content: input_value.clone() };
Enter fullscreen mode Exit fullscreen mode

The output of the TodoList component code will look like so: Same Text Input Component Shown As In Previous Image, Now With List Of To Do Items Beneath That’s all there is to it. We now have a fully functional to-do application. You can find the full code on GitHub.

However, as you can see, our app doesn’t look very pretty. Of course, this is because there’s no CSS yet. Let’s take a look at how to add CSS to your Leptos application.

Styling components in Leptos

Leptos has no opinion about how you add CSS to your Leptos application. It allows you to use any strategy or CSS framework that you prefer.

For example, you could add an inline style like so: <input style="background:red" />. This should change the background of the input to red.

You can also add your CSS to the <head> element using the <style> element in the index.html file. For example:

<!DOCTYPE html>
<html>
  <meta charset="UTF-8">

<head>
  <style>
    .todo-list li:hover .remove {
      display: block;
    }
  </style>
</head>
<body></body>
</html>
Enter fullscreen mode Exit fullscreen mode

Leptos also seamlessly integrates with Tailwind CSS, enabling you to utilize Tailwind's utility classes directly in your HTML markup. For example, to create a heading with the p-6 and text-4xl classes, you would write:

<h2 class="p-6 text-4xl">"Welcome to Leptos with Tailwind"> Hello World!</h2>
Enter fullscreen mode Exit fullscreen mode

In our example, we’ll keep it simple and just add the CSS in the <head> of the index.html file. There’s a lot of code, so I’ll strip most of it to show you a simple example:

<!DOCTYPE html>
<html>
  <meta charset="UTF-8">
  <head>
    <style>
      html,
      body {
        font: 13px 'Arial', sans-serif;
        line-height: 1.5em;
        background: #a705a4;
        color: #4d4d4d;
        min-width: 399px;
        max-width: 799px;
        margin: 0 auto;
        font-weight: 250;
      }
    </style>
</head>
<body></body>
</html>
Enter fullscreen mode Exit fullscreen mode

You can get the complete CSS we used to style the to-do app from the GitHub repository.

Now that we've added the CSS, we're ready to replicate the demo to-do application we saw earlier.

To run the app, simply execute the command trunk serve --open. This will compile the application and open it in your default browser on port 8080. The output should resemble the following:

Similar Rust frontend web frameworks

I've tried at least five different Rust frontend web frameworks, and in my opinion, Leptos stands out as an excellent choice.

Leptos is relatively easy to learn and uses fine-grained reactivity, which sets it apart from frameworks like Dioxus. Focusing on fine-grain reactivity rather than the virtual DOM also aligns with the latest trends in frontend development and offers significant performance advantages.

Here is a comparison table for similar Rust frontend web frameworks:

Framework GitHub stars Virtual DOM Server-side rendering (SSR) Rendering method Architecture
Dioxus 14.5K Yes Yes HTML React/Redux
Egui 17.1K No No Canvas ImGUI
Iced 21K No No Canvas TEA
Leptos 12.6K No Yes HTML FRP
MoonZoon 1.6K No No HTML FRP
Sauron 1.8K No Yes HTML FRP
Perseus 2K No Yes HTML FRP

Conclusion

Leptos is an amazing Rust web frontend framework. It was built on the premise of scalability and making it a lot easier to build declarative UIs without sacrificing performance.

We’ve really just scratched the surface of Leptos with this tutorial. There is a lot more you can do with Leptos — I recommend visiting the Leptos documentation for more ideas.

You can check out the GitHub repository that contains all the examples in this tutorial as a reference. If you have any questions, feel free to comment them below.

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)