Posted on ::

Dioxus, Events and States

So I've been playing around with Dioxus for a note taking app I've been working on. I could write extensively just to explain why I'm writing a note taking app in such a busy market of note taking apps, or why I chose Dioxus over other UI frameworks in Rust (also we can talk about the decision of using Rust) such as Egui or Iced, but that's not the goal of this post... and maybe a topic for another post?

So to the point: I'm not a frontend developer, I used to do Mobile development (and I think I was pretty decent) but that was a long time ago. Anyway, I like how Dioxus works (which is heavily inspired by React), the idea of reactive UI based on state changes is great and helped me to be productive and get things working pretty fast. But there's one thing I was missing: Event Subscription.

I have many components that should be aware of what other components are doing, or at least what another component recently did. I could share the state across these components, but that would mean to carry around said state on every component call. Dioxus provides a global state with use_context() which works pretty well, but there's another thing: State sharing is good, but I also need events.

Anyway, I decided to create my own publisher/subscriber (or PubSub from now on), and use the aforementioned use_context() to share this PubSub across components. And now I'm sharing it with you in case it's useful, or in case you want to tell me there's a better option for this and my design sucks.

Let's get to work

The design is pretty simple, we need:

  • A struct, which we will call PubSub, that contains a set of Subscriptions
  • Each Subscription should have a callback that will be called when publishing an event
  • And our PubSub should have three functions:
    • subscribe(id, subscription) for subscribing a component, passing an ID for reference
    • publish(event) for sending an event for each subscribed component so they execute the callback with said event
    • unsubscribe(id) because is nice to do housekeeping

Let's start with the Subscription:

#[derive(Clone)]
struct Subscription<E>
where
    E: Clone + 'static,
{
    callback: Callback<E>,
}

Pretty simple huh? You can see we are using a generic event, it is up to you to use whatever you want to publish, so it can be an enum, or a full struct. Actually, our subscription could even be just the Callback<E> and we should be good. I'm encapsulating it inside the struct just to be more intentional with the code, but actually shouldn't be necessary. We can add a new(callback) function to make things easy when creating subscriptions.

The reason we implement Clone is because we want to use use_context() to share our PubSub, which requires to have cloneable states.

Let's go with our PubSub:

#[derive(Clone)]
pub struct PubSub<E>
where
    E: Clone + 'static,
{
    subscribers: Rc<RefCell<HashMap<String, Subscription<E>>>>,
}

That's it. A HashMap with a string as the key for referring to the Subscription ID, and the actual subscription. Why the Rc and RefCell? Well, the Dioxus Documentation states that the context once provided is immutable and any changes must be done using interior mutability, so we encapsulate out subscriptions in a RefCell. The other thing we want to make sure, is that when our context provider clones our PubSub, we are referencing to the same list of subscriptions so instead of storing the list directly, we store a reference to it using our Reference Counter Rc.

We now can create the methods, which are pretty straightforward:

impl<E> PubSub<E>
where
    E: Clone + 'static,
{
    pub fn new() -> Self {
        Self {
            subscribers: Rc::new(RefCell::new(HashMap::default())),
        }
    }

    pub fn subscribe<S: AsRef<str>>(&self, id: S, callback: Callback<E>) {
        self.subscribers
            .borrow_mut()
            .insert(id.as_ref().to_string(), Subscription::new(callback));
    }
    pub fn unsubscribe<S: AsRef<str>>(&self, id: S) {
        self.subscribers.borrow_mut().remove(id.as_ref());
    }
    pub fn publish(&self, event: E) {
        for subscription in self.subscribers.borrow().values() {
            subscription.callback.call(event.clone());
        }
    }
}

And ta-daaa, that's it. How do we use it? When we start the app, we provide our PubSub to the context provider (here I'm using a enum called GlobalEvent as the event to publish/subscribe):

let pub_sub = PubSub::<GlobalEvent>::new();
use_context_provider(move || pub_sub);

And in any component we want to react to an event, we subscribe. For example in our text editor when we receive an event to save a note:

let pub_sub: PubSub<GlobalEvent> = use_context();
use_effect(move || {
  pub_sub.subscribe(
    "My Component",
    Callback::new(move |e| {
      match e {
        GlobalEvent::Save => {
          // Call your magic here
          save_note();
        }
        _ => {}
      }
    });
  );
});

Now everywhere we want to trigger a save action (a button callback, a hotkey, a menu selection), we just have to send the event:

let pub_sub: PubSub<GlobalEvent> = use_context();

rsx! {
  button {
    class: "save-button",
    onclick: move |e| {
      pub_sub.publish(GlobalEvent::Save);
    },
    "Save"
  }
}

There's one last thing we need to do. We don't want references lingering around when a component is unloaded, so it is a good practice to clean up our unused subscriptions. Fortunately, Dioxus provides a handy method when a component is about to be unloaded: use_drop(). As the documentation states:

Creates a callback that will be run before the component is removed.

So this looks like the perfect place to do our housekeeping, for the example above, it should look like:

use_drop(move || pub_sub.unsubscribe("My Component"));

We only need to know, that everywhere we create a subscription, we have to remove that subscription when the component is dropped.

And that's it. Fin.

Table of Contents