Your First Component

This chapter will teach you how to create a Component that displays a link to a post on hackernews.

Setup

Before you start the guide, make sure you have the dioxus CLI and any required dependencies for your platform as described in the getting started guide.

First, let's create a new project for our hacker news app. We can use the CLI to create a new project. You can select a platform of your choice or view the getting started guide for more information on each option. If you aren't sure what platform to try out, we recommend getting started with web or desktop:

dx new

The template contains some boilerplate to help you get started. For this guide, we will be rebuilding some of the code from scratch for learning purposes. You can clear the src/main.rs file. We will be adding new code in the next sections.

Next, let's setup our dependencies. We need to set up a few dependencies to work with the hacker news API:

cargo add chrono --features serde
cargo add futures
cargo add reqwest --features json
cargo add serde --features derive
cargo add serde_json
cargo add async_recursion

Describing the UI

Now, we can define how to display a post. Dioxus is a declarative framework. This means that instead of telling Dioxus what to do (e.g. to "create an element" or "set the color to red") we simply declare how we want the UI to look.

To declare what you want your UI to look like, you will need to use the rsx macro. Let's create a main function and an App component to show information about our story:

src/hackernews_post.rs
fn main() {
    launch(App);
}

pub fn App() -> Element {
    rsx! {"story"}
}

Now if you run your application you should see something like this:

story

RSX mirrors HTML. Because of this you will need to know some html to use Dioxus.

Here are some resources to help get you started learning HTML:

In addition to HTML, Dioxus uses CSS to style applications. You can either use traditional CSS (what this guide uses) or use a tool like tailwind CSS:

If you have existing html code, you can use the translate command to convert it to RSX. Or if you prefer to write html, you can use the html! macro to write html directly in your code.

Dynamic Text

Let's expand our App component to include the story title, author, score, time posted, and number of comments. We can insert dynamic text in the render macro by inserting variables inside {}s (this works similarly to the formatting in the println! macro):

src/hackernews_post.rs
pub fn App() -> Element {
    let title = "title";
    let by = "author";
    let score = 0;
    let time = chrono::Utc::now();
    let comments = "comments";

    rsx! {"{title} by {by} ({score}) {time} {comments}"}
}
title by author (0) 2024-11-16 05:38:56.337625289 UTC comments

Creating Elements

Next, let's wrap our post description in a div. You can create HTML elements in Dioxus by putting a { after the element name and a } after the last child of the element:

src/hackernews_post.rs
pub fn App() -> Element {
    let title = "title";
    let by = "author";
    let score = 0;
    let time = chrono::Utc::now();
    let comments = "comments";

    rsx! { div { "{title} by {by} ({score}) {time} {comments}" } }
}
title by author (0) 2024-11-16 05:38:56.337637311 UTC comments

You can read more about elements in the rsx reference.

Setting Attributes

Next, let's add some padding around our post listing with an attribute.

Attributes (and listeners) modify the behavior or appearance of the element they are attached to. They are specified inside the {} brackets before any children, using the name: value syntax. You can format the text in the attribute as you would with a text node:

src/hackernews_post.rs
pub fn App() -> Element {
    let title = "title";
    let by = "author";
    let score = 0;
    let time = chrono::Utc::now();
    let comments = "comments";

    rsx! {
        div { padding: "0.5rem", position: "relative",
            "{title} by {by} ({score}) {time} {comments}"
        }
    }
}
title by author (0) 2024-11-16 05:38:56.337648172 UTC comments

Note: All attributes defined in dioxus-html follow the snake_case naming convention. They transform their snake_case names to HTML's camelCase attributes.

Note: Styles can be used directly outside of the style: attribute. In the above example, padding: "0.5rem" is turned into style="padding: 0.5rem".

You can read more about elements in the attribute reference

Creating a Component

Just like you wouldn't want to write a complex program in a single, long, main function, you shouldn't build a complex UI in a single App function. Instead, you should break down the functionality of an app in logical parts called components.

A component is a Rust function, named in UpperCamelCase, that takes a props parameter and returns an Element describing the UI it wants to render. In fact, our App function is a component!

Let's pull our story description into a new component:

src/hackernews_post.rs
fn StoryListing() -> Element {
    let title = "title";
    let by = "author";
    let score = 0;
    let time = chrono::Utc::now();
    let comments = "comments";

    rsx! {
        div { padding: "0.5rem", position: "relative",
            "{title} by {by} ({score}) {time} {comments}"
        }
    }
}

We can render our component like we would an element by putting {}s after the component name. Let's modify our App component to render our new StoryListing component:

src/hackernews_post.rs
pub fn App() -> Element {
    rsx! { StoryListing {} }
}
title by author (0) 2024-11-16 05:38:56.337667478 UTC comments

You can read more about elements in the component reference

Creating Props

Just like you can pass arguments to a function or attributes to an element, you can pass props to a component that customize its behavior!

We can define arguments that components can take when they are rendered (called Props) by adding the #[component] macro before our function definition and adding extra function arguments.

Currently, our StoryListing component always renders the same story. We can modify it to accept a story to render as a prop.

We will also define what a post is and include information for how to transform our post to and from a different format using serde. This will be used with the hackernews API in a later chapter:

src/hackernews_post.rs
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

// Define the Hackernews types
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StoryPageData {
    #[serde(flatten)]
    pub item: StoryItem,
    #[serde(default)]
    pub comments: Vec<CommentData>,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CommentData {
    pub id: i64,
    /// there will be no by field if the comment was deleted
    #[serde(default)]
    pub by: String,
    #[serde(default)]
    pub text: String,
    #[serde(with = "chrono::serde::ts_seconds")]
    pub time: DateTime<Utc>,
    #[serde(default)]
    pub kids: Vec<i64>,
    #[serde(default)]
    pub sub_comments: Vec<CommentData>,
    pub r#type: String,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StoryItem {
    pub id: i64,
    pub title: String,
    pub url: Option<String>,
    pub text: Option<String>,
    #[serde(default)]
    pub by: String,
    #[serde(default)]
    pub score: i64,
    #[serde(default)]
    pub descendants: i64,
    #[serde(with = "chrono::serde::ts_seconds")]
    pub time: DateTime<Utc>,
    #[serde(default)]
    pub kids: Vec<i64>,
    pub r#type: String,
}

#[component]
fn StoryListing(story: ReadOnlySignal<StoryItem>) -> Element {
    let StoryItem {
        title,
        url,
        by,
        score,
        time,
        kids,
        ..
    } = &*story.read();

    let comments = kids.len();

    rsx! {
        div { padding: "0.5rem", position: "relative",
            "{title} by {by} ({score}) {time} {comments}"
        }
    }
}

Make sure to also add serde as a dependency:

cargo add serde --features derive
cargo add serde_json

We will also use the chrono crate to provide utilities for handling time data from the hackernews API:

cargo add chrono --features serde

Now, let's modify the App component to pass the story to our StoryListing component like we would set an attribute on an element:

src/hackernews_post.rs
pub fn App() -> Element {
    rsx! {
        StoryListing {
            story: StoryItem {
                id: 0,
                title: "hello hackernews".to_string(),
                url: None,
                text: None,
                by: "Author".to_string(),
                score: 0,
                descendants: 0,
                time: chrono::Utc::now(),
                kids: vec![],
                r#type: "".to_string(),
            }
        }
    }
}
hello hackernews by Author (0) 2024-11-16 05:38:56.337754090 UTC 0

You can read more about Props in the Props reference

Cleaning Up Our Interface

Finally, by combining elements and attributes, we can make our post listing much more appealing:

Full code up to this point:

src/hackernews_post.rs
use dioxus::prelude::*;

// Define the Hackernews types
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StoryPageData {
    #[serde(flatten)]
    pub item: StoryItem,
    #[serde(default)]
    pub comments: Vec<CommentData>,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CommentData {
    pub id: i64,
    /// there will be no by field if the comment was deleted
    #[serde(default)]
    pub by: String,
    #[serde(default)]
    pub text: String,
    #[serde(with = "chrono::serde::ts_seconds")]
    pub time: DateTime<Utc>,
    #[serde(default)]
    pub kids: Vec<i64>,
    #[serde(default)]
    pub sub_comments: Vec<CommentData>,
    pub r#type: String,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StoryItem {
    pub id: i64,
    pub title: String,
    pub url: Option<String>,
    pub text: Option<String>,
    #[serde(default)]
    pub by: String,
    #[serde(default)]
    pub score: i64,
    #[serde(default)]
    pub descendants: i64,
    #[serde(with = "chrono::serde::ts_seconds")]
    pub time: DateTime<Utc>,
    #[serde(default)]
    pub kids: Vec<i64>,
    pub r#type: String,
}

fn main() {
    launch(App);
}

pub fn App() -> Element {
    rsx! {
        StoryListing {
            story: StoryItem {
                id: 0,
                title: "hello hackernews".to_string(),
                url: None,
                text: None,
                by: "Author".to_string(),
                score: 0,
                descendants: 0,
                time: Utc::now(),
                kids: vec![],
                r#type: "".to_string(),
            }
        }
    }
}

#[component]
fn StoryListing(story: ReadOnlySignal<StoryItem>) -> Element {
    let StoryItem {
        title,
        url,
        by,
        score,
        time,
        kids,
        ..
    } = &*story.read();

    let url = url.as_deref().unwrap_or_default();
    let hostname = url
        .trim_start_matches("https://")
        .trim_start_matches("http://")
        .trim_start_matches("www.");
    let score = format!("{score} {}", if *score == 1 { " point" } else { " points" });
    let comments = format!(
        "{} {}",
        kids.len(),
        if kids.len() == 1 {
            " comment"
        } else {
            " comments"
        }
    );
    let time = time.format("%D %l:%M %p");

    rsx! {
        div { padding: "0.5rem", position: "relative",
            div { font_size: "1.5rem",
                a { href: url, "{title}" }
                a {
                    color: "gray",
                    href: "https://news.ycombinator.com/from?site={hostname}",
                    text_decoration: "none",
                    " ({hostname})"
                }
            }
            div { display: "flex", flex_direction: "row", color: "gray",
                div { "{score}" }
                div { padding_left: "0.5rem", "by {by}" }
                div { padding_left: "0.5rem", "{time}" }
                div { padding_left: "0.5rem", "{comments}" }
            }
        }
    }
}
0 points
by Author
11/16/24 5:38 AM
0 comments