Conclusion
Well done! You've completed the Dioxus guide and built a hackernews application in Dioxus.
To continue your journey, you can attempt a challenge listed below, or look at the Dioxus reference.
Challenges
- Organize your components into separate files for better maintainability.
- Give your app some style if you haven't already.
- Integrate your application with the Dioxus router.
The full code for the hacker news project
src/hackernews_complete.rs
#![allow(non_snake_case)]
use dioxus::prelude::*;
fn main() {
launch(App);
}
pub fn App() -> Element {
use_context_provider(|| Signal::new(PreviewState::Unset));
rsx! {
div { display: "flex", flex_direction: "row", width: "100%",
div { width: "50%", Stories {} }
div { width: "50%", Preview {} }
}
}
}
fn Stories() -> Element {
let stories = use_resource(move || get_stories(10));
match &*stories.read_unchecked() {
Some(Ok(list)) => rsx! {
div {
for story in list {
StoryListing { story: story.clone() }
}
}
},
Some(Err(err)) => rsx! {"An error occurred while fetching stories {err}"},
None => rsx! {"Loading items"},
}
}
async fn resolve_story(
mut full_story: Signal<Option<StoryPageData>>,
mut preview_state: Signal<PreviewState>,
story_id: i64,
) {
if let Some(cached) = full_story.as_ref() {
*preview_state.write() = PreviewState::Loaded(cached.clone());
return;
}
*preview_state.write() = PreviewState::Loading;
if let Ok(story) = get_story(story_id).await {
*preview_state.write() = PreviewState::Loaded(story.clone());
*full_story.write() = Some(story);
}
}
#[component]
fn StoryListing(story: ReadOnlySignal<StoryItem>) -> Element {
let preview_state = consume_context::<Signal<PreviewState>>();
let StoryItem {
title,
url,
by,
score,
time,
kids,
id,
..
} = story();
let full_story = use_signal(|| None);
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",
onmouseenter: move |_event| { resolve_story(full_story, preview_state, id) },
div { font_size: "1.5rem",
a {
href: url,
onfocus: move |_event| { resolve_story(full_story, preview_state, id) },
"{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}" }
}
}
}
}
#[derive(Clone, Debug)]
enum PreviewState {
Unset,
Loading,
Loaded(StoryPageData),
}
fn Preview() -> Element {
let preview_state = consume_context::<Signal<PreviewState>>();
match preview_state() {
PreviewState::Unset => rsx! {"Hover over a story to preview it here"},
PreviewState::Loading => rsx! {"Loading..."},
PreviewState::Loaded(story) => {
rsx! {
div { padding: "0.5rem",
div { font_size: "1.5rem", a { href: story.item.url, "{story.item.title}" } }
div { dangerous_inner_html: story.item.text }
for comment in &story.comments {
Comment { comment: comment.clone() }
}
}
}
}
}
}
#[component]
fn Comment(comment: CommentData) -> Element {
rsx! {
div { padding: "0.5rem",
div { color: "gray", "by {comment.by}" }
div { dangerous_inner_html: "{comment.text}" }
for kid in &comment.sub_comments {
Comment { comment: kid.clone() }
}
}
}
}
// Define the Hackernews API and types
use chrono::{DateTime, Utc};
use futures::future::join_all;
use serde::{Deserialize, Serialize};
pub static BASE_API_URL: &str = "https://hacker-news.firebaseio.com/v0/";
pub static ITEM_API: &str = "item/";
pub static USER_API: &str = "user/";
const COMMENT_DEPTH: i64 = 2;
pub async fn get_story_preview(id: i64) -> Result<StoryItem, reqwest::Error> {
let url = format!("{}{}{}.json", BASE_API_URL, ITEM_API, id);
reqwest::get(&url).await?.json().await
}
pub async fn get_stories(count: usize) -> Result<Vec<StoryItem>, reqwest::Error> {
let url = format!("{}topstories.json", BASE_API_URL);
let stories_ids = &reqwest::get(&url).await?.json::<Vec<i64>>().await?[..count];
let story_futures = stories_ids[..usize::min(stories_ids.len(), count)]
.iter()
.map(|&story_id| get_story_preview(story_id));
Ok(join_all(story_futures)
.await
.into_iter()
.filter_map(|story| story.ok())
.collect())
}
#[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,
}
pub async fn get_story(id: i64) -> Result<StoryPageData, reqwest::Error> {
let url = format!("{}{}{}.json", BASE_API_URL, ITEM_API, id);
let mut story = reqwest::get(&url).await?.json::<StoryPageData>().await?;
let comment_futures = story.item.kids.iter().map(|&id| get_comment(id));
let comments = join_all(comment_futures)
.await
.into_iter()
.filter_map(|c| c.ok())
.collect();
story.comments = comments;
Ok(story)
}
#[async_recursion::async_recursion(?Send)]
pub async fn get_comment_with_depth(id: i64, depth: i64) -> Result<CommentData, reqwest::Error> {
let url = format!("{}{}{}.json", BASE_API_URL, ITEM_API, id);
let mut comment = reqwest::get(&url).await?.json::<CommentData>().await?;
if depth > 0 {
let sub_comments_futures = comment
.kids
.iter()
.map(|story_id| get_comment_with_depth(*story_id, depth - 1));
comment.sub_comments = join_all(sub_comments_futures)
.await
.into_iter()
.filter_map(|c| c.ok())
.collect();
}
Ok(comment)
}
pub async fn get_comment(comment_id: i64) -> Result<CommentData, reqwest::Error> {
get_comment_with_depth(comment_id, COMMENT_DEPTH).await
}