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:
fn main() { launch(App); } pub fn App() -> Element { rsx! {"story"} }
Now if you run your application you should see something like this:
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:
- MDN Traditional CSS Guide
- W3 Schools Traditional CSS Tutorial
- Tailwind tutorial (used with the Tailwind setup example)
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() -> 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}"} }
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() -> 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}" } } }
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() -> 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}" } } }
Note: All attributes defined in
dioxus-html
follow the snake_case naming convention. They transform theirsnake_case
names to HTML'scamelCase
attributes.
Note: Styles can be used directly outside of the
style:
attribute. In the above example,padding: "0.5rem"
is turned intostyle="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:
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:
pub fn App() -> Element { rsx! { StoryListing {} } }
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<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:
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(), } } } }
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<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}" } } } } }