Interactivity

In this chapter, we will add a preview for articles you hover over or links you focus on.

Creating a Preview

First, let's split our app into a Stories component on the left side of the screen, and a preview component on the right side of the screen:

src/hackernews_state.rs
pub fn App() -> Element {
    rsx! {
        div { display: "flex", flex_direction: "row", width: "100%",
            div { width: "50%", Stories {} }
            div { width: "50%", Preview {} }
        }
    }
}

// New
fn Stories() -> 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(),
            }
        }
    }
}

// New
#[derive(Clone, Debug)]
enum PreviewState {
    Unset,
    Loading,
    Loaded(StoryPageData),
}

// New
fn Preview() -> Element {
    let preview_state = PreviewState::Unset;
    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() }
                    }
                }
            }
        }
    }
}

// NEW
#[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() }
            }
        }
    }
}
0 points
by Author
11/16/24 5:38 AM
0 comments
Hover over a story to preview it here

Event Handlers

Next, we need to detect when the user hovers over a section or focuses a link. We can use an event listener to listen for the hover and focus events.

Event handlers are similar to regular attributes, but their name usually starts with on- and they accept closures as values. The closure will be called whenever the event it listens for is triggered. When an event is triggered, information about the event is passed to the closure through the Event structure.

Let's create a onmouseenter event listener in the StoryListing component:

src/hackernews_state.rs
rsx! {
    div {
        padding: "0.5rem",
        position: "relative",
        onmouseenter: move |_| {},
        div { font_size: "1.5rem",
            a { href: url, onfocus: move |_event| {}, "{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}" }
        }
    }
}

You can read more about Event Handlers in the Event Handler reference

State

So far our components have had no state like normal rust functions. To make our application change when we hover over a link we need state to store the currently hovered link in the root of the application.

You can create state in dioxus using hooks. Hooks are Rust functions you call in a constant order in a component that add additional functionality to the component.

In this case, we will use the use_context_provider and use_context hooks:

  • You can provide a closure to use_context_provider that determines the initial value of the shared state and provides the value to all child components
  • You can then use the use_context hook to read and modify that state in the Preview and StoryListing components
  • When the value updates, the Signal will cause the component to re-render, and provides you with the new value

Note: You should prefer local state hooks like use_signal or use_signal_sync when you only use state in one component. Because we use state in multiple components, we can use a global state pattern

src/hackernews_state.rs
pub fn App() -> Element {
    use_context_provider(|| Signal::new(PreviewState::Unset));
src/hackernews_state.rs
#[component]
fn StoryListing(story: ReadOnlySignal<StoryItem>) -> Element {
    let mut preview_state = consume_context::<Signal<PreviewState>>();
    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} point{}", if *score > 1 { "s" } else { "" });
    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| {
                *preview_state
                    .write() = PreviewState::Loaded(StoryPageData {
                    item: story(),
                    comments: vec![],
                });
            },
            div { font_size: "1.5rem",
                a {
                    href: url,
                    onfocus: move |_event| {
                        *preview_state
                            .write() = PreviewState::Loaded(StoryPageData {
                            item: story(),
                            comments: vec![],
                        });
                    },
src/hackernews_state.rs
fn Preview() -> Element {
    // New
    let preview_state = consume_context::<Signal<PreviewState>>();

    // New
    match preview_state() {
0 point
by Author
11/16/24 5:38 AM
0 comments
Hover over a story to preview it here

You can read more about Hooks in the Hooks reference

The Rules of Hooks

Hooks are a powerful way to manage state in Dioxus, but there are some rules you need to follow to insure they work as expected. Dioxus uses the order you call hooks to differentiate between hooks. Because the order you call hooks matters, you must follow these rules:

  1. Hooks may be only used in components or other hooks (we'll get to that later)
  2. On every call to the component function
    1. The same hooks must be called
    2. In the same order
  3. Hooks name's should start with use_ so you don't accidentally confuse them with regular functions

These rules mean that there are certain things you can't do with hooks:

No Hooks in Conditionals

src/hackernews_state.rs
// ❌ don't call hooks in conditionals!
// We must ensure that the same hooks will be called every time
// But `if` statements only run if the conditional is true!
// So we might violate rule 2.
if you_are_happy && you_know_it {
    let something = use_signal(|| "hands");
    println!("clap your {something}")
}

// ✅ instead, *always* call use_signal
// You can put other stuff in the conditional though
let something = use_signal(|| "hands");
if you_are_happy && you_know_it {
    println!("clap your {something}")
}

No Hooks in Closures

src/hackernews_state.rs
// ❌ don't call hooks inside closures!
// We can't guarantee that the closure, if used, will be called in the same order every time
let _a = || {
    let b = use_signal(|| 0);
    b()
};

// ✅ instead, move hook `b` outside
let b = use_signal(|| 0);
let _a = || b();

No Hooks in Loops

src/hackernews_state.rs
// `names` is a Vec<&str>

// ❌ Do not use hooks in loops!
// In this case, if the length of the Vec changes, we break rule 2
for _name in &names {
    let is_selected = use_signal(|| false);
    println!("selected: {is_selected}");
}

// ✅ Instead, use a hashmap with use_signal
let selection_map = use_signal(HashMap::<&str, bool>::new);

for name in &names {
    let is_selected = selection_map.read()[name];
    println!("selected: {is_selected}");
}