dioxus icon indicating copy to clipboard operation
dioxus copied to clipboard

Improve ergonomics of async integration

Open chrivers opened this issue 10 months ago • 3 comments

Feature Request

This is a spin-off from #4011 ("Async closure for "onclick": spawns into the aether").

Related issue: #3481

When interfacing with a rest api, and targeting web, there's really no other choice than using an async http client. This means lots of async integration in event handlers.

In general, this can work:

rsx! {
    div {
        onclick: move |_| {
            async move {
                // ...
            }
        }
    }
}

However, it's not that ergonomic to use:

1. The "magic" async support works by returning a future from the event handler

This means there's no way to use an async block in the middle of the event handler. The entire "bottom half" (or the whole) of the event handler must be async.

2. There's (seemingly?) no way to .await the async block

This makes it extra difficult to make things happen in a certain order.

For example, I tried to make a button component that displays a spinner while the .onclick is running. Conceptually, something like this:

#[component]
pub fn SpinnerButton(onclick: EventHandler<MouseEvent>, running: Signal<bool>) -> Element {
    rsx! {
        button {
            onclick: move |evt| {
                running.set(true);
                onclick.call(evt);
                running.set(false);
            }
        }
    }
}

This seems reasonable, no?

Well, if onclick is given as an async move block, it's wrapped in something that causes it to be spawned, but not awaited in a blocking-like way. The result is that .call() exits immediately, and so the SpinnerButton concept appears broken.

This is pretty surprising, and I haven't been able to find a workaround of any kind.

Suggestion

In #4011, @ealmloff had this comment:

This is interesting. Async event handler were originally added for just elements, but they were later expanded into components to make behavior more consistent.

It seems reasonable to return a status object that implements IntoFuture so you could await the result.

This would be a good issue to spin out from this thread.

As a workaround, you can accept a closure that returns a boxed future explicitly with Callback<EventHandler, Pin<Box<dyn Future<Output = T>>>

The workaround is certainly a neat idea, but it does come with some problems:

  • The component would then only be able to accept async closures
  • There's still no way to tell when the handler is done running

I think there's a design problem with the way async closures are handled right now.

In other words:

  callback.call();

Should wait until the closure is done running, no matter if it's sync or async.

I'm not sure how exactly to implement this, but that would solve this entire issue.

Anyone who explicitly wants to spawn a background task, can already do so with the spawn() helper.

I can't really see any downside to the blocking behavior, but there's plenty of advantages.

Thoughts?

chrivers avatar Apr 19 '25 14:04 chrivers

To clarify, with the callback you need to await the result of the call like this. The rest of the closure will only run when the await is finished:

#[component]
pub fn SpinnerButton(
    onclick: Callback<MouseEvent, Pin<Box<dyn Future<Output = ()>>>>,
    running: Signal<bool>,
) -> Element {
    rsx! {
        button {
            onclick: move |evt| async move {
                running.set(true);
                onclick(evt).await;
                running.set(false);
            }
        }
    }
}

I can't really see any downside to the blocking behavior, but there's plenty of advantages.

The browser runtime is largely single threaded which means we cannot block the main thread without freezing the whole page. If this is implemented, we would need to return a future from the maybe-future event handler call so you could await it when you call the event handler like in the code snippet above

ealmloff avatar Apr 21 '25 14:04 ealmloff

Thank you for explaining the issue, I think I see the problem more clearly now.

I'm not sure how the internal mechanics work. It certainly seems like async tasks are supported, but that's all on a single thread. Is that correct so far?

Hm, I was wondering if there was some way to make the hypothetical waiting-for-completion task.call() not stall.

In pseudo-code, is it possible to call a function that runs "other things", until completion. Kind of like this rough sketch:

impl Event {
    pub fn call(&self) -> {
        magical_async_reactor_core.run_stuff_until_done(self.future);
    }
}

I know that's a rough sketch, but do you see what I mean?

I'll admit, I have no idea if that's possible, or feasible. It's very hand-wavy, but it would make for an easy interface for programmers.

Alternatively, would it make since for event handlers to always be async? That would also make integrations smoother.

But in the end, your suggestion for a workaround is pretty solid. It's a bit verbose, but very manageable with a type alias.

chrivers avatar Apr 22 '25 14:04 chrivers

I tried using the pinned box workaround mentioned above. I was able to create the component itself that took the callback as a prop, but I wasn't able to invoke that component in other component. I tried passing an async closure like you usually do with on* handlers, but the automatic conversion to a Callback didn't work (and failed with a pretty cryptic error message). How can I do this?

For now, I ended up creating a use_async_callback hook that wraps use_callback and manually Box::pins the resulting future, but a solution that doesn't require (explicit) hooks would be helpful.

OxleyS avatar Jan 07 '26 17:01 OxleyS

For now, I ended up creating a use_async_callback hook that wraps use_callback and manually Box::pins the resulting future, but a solution that doesn't require (explicit) hooks would be helpful.

How? I can't seem to have any success with creating asynchronous callbacks..

let onclick = use_callback(move |event: Event<MouseData>| async move {
        // do something async
});

Can't feed that into button onclick property.

pronebird avatar Jan 25 '26 09:01 pronebird

Here's the code I'm using, it's possible this can be improved on but it's getting the job done right now.

use dioxus::prelude::*;

pub type BoxedFuture<T = ()> = std::pin::Pin<Box<dyn Future<Output = T>>>;
pub type AsyncCallback<Args = (), Ret = ()> = Callback<Args, BoxedFuture<Ret>>;

/// A version of `use_callback` that allows the callback to be awaited and the return value read by the component invoking the callback.
pub fn use_async_callback<Args: 'static, Ret: 'static, F, Fut>(mut f: F) -> AsyncCallback<Args, Ret>
where
    F: FnMut(Args) -> Fut + 'static,
    Fut: Future<Output = Ret> + 'static,
{
    // Type inference fails without this sub-function for whatever reason
    pub fn call_and_pin<Args: 'static, Ret: 'static, F, Fut>(
        f: &mut F,
        args: Args,
    ) -> std::pin::Pin<Box<dyn Future<Output = Ret>>>
    where
        F: FnMut(Args) -> Fut + 'static,
        Fut: Future<Output = Ret> + 'static,
    {
        Box::pin(f(args))
    }

    use_callback(move |args: Args| call_and_pin(&mut f, args))
}

then used like:

let do_something = use_async_callback(move |arg| async move {
  something_async(arg).await;
  
  // The callback can return values, and the invoker can await the callback and get that returned value.
  Ok(())                                                                                    
});

rsx! {
  button {
    onclick: move |_| async move {
      let result = do_something(arg).await;
      // Do other stuff
    },
  }
}

OxleyS avatar Jan 26 '26 16:01 OxleyS