Your First Component

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

First, let's 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 modify the rsx macro in the App function from the getting started to show information about our story:

pub fn App(cx: Scope) -> Element {
    render! {
        "story"
    }
}

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):

pub fn App(cx: Scope) -> Element {
    let title = "title";
    let by = "author";
    let score = 0;
    let time = chrono::Utc::now();
    let comments = "comments";

    render! {
        "{title} by {by} ({score}) {time} {comments}"
    }
}
title by author (0) 2024-02-16 14:50:14.387178241 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:

pub fn App(cx: Scope) -> Element {
    let title = "title";
    let by = "author";
    let score = 0;
    let time = chrono::Utc::now();
    let comments = "comments";

    render! {
        div {
            "{title} by {by} ({score}) {time} {comments}"
        }
    }
}
title by author (0) 2024-02-16 14:50:14.387210411 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:

pub fn App(cx: Scope) -> Element {
    let title = "title";
    let by = "author";
    let score = 0;
    let time = chrono::Utc::now();
    let comments = "comments";

    render! {
        div {
            padding: "0.5rem",
            position: "relative",
            "{title} by {by} ({score}) {time} {comments}"
        }
    }
}
title by author (0) 2024-02-16 14:50:14.387232993 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, color: "red" is turned into style="color: red".

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 Scope 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:

fn StoryListing(cx: Scope) -> Element {
    let title = "title";
    let by = "author";
    let score = 0;
    let time = chrono::Utc::now();
    let comments = "comments";

    render! {
        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:

pub fn App(cx: Scope) -> Element {
    render! {
        StoryListing {

        }
    }
}
title by author (0) 2024-02-16 14:50:14.387272236 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:

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<Comment>,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Comment {
    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<Comment>,
    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(cx: Scope, story: StoryItem) -> Element {
    let StoryItem {
        title,
        by,
        score,
        time,
        kids,
        ..
    } = story;

    let comments = kids.len();

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

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

pub fn App(cx: Scope) -> Element {
    render! {
        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-02-16 14:50:14.387296461 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:

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<Comment>,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Comment {
    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<Comment>,
    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,
}

pub fn App(cx: Scope) -> Element {
    render! {
        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(cx: Scope, story: StoryItem) -> Element {
    let StoryItem {
        title,
        url,
        by,
        score,
        time,
        kids,
        id,
        ..
    } = story;

    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");

    cx.render(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
02/16/24 2:50 PM
0 comments