Skip to content

feat: add clonable immutable Handler#146

Merged
ccbrown merged 9 commits intoccbrown:mainfrom
barsoosayque:ref-handler
Oct 28, 2025
Merged

feat: add clonable immutable Handler#146
ccbrown merged 9 commits intoccbrown:mainfrom
barsoosayque:ref-handler

Conversation

@barsoosayque
Copy link
Contributor

What It Does

Add an immutable variant of Handler.

By default, Handler can mutate captured values. It's useful for buttons and other components that can mutate state directly, but it limits reusability of the Handler, especially when used with hooks.use_async_handler.

For example, I have a handler that reacts to component actions:

#[derive(Debug, Clone, Copy)]
enum Action { ToggleFoo, ToggleBar }

let action = hooks.use_async_handler(move |action: Action| async move {
    match action {
        Action::ToggleFoo => toggle_foo().await.unwrap(),
        Action::ToggleBar => toggle_bar().await.unwrap(),
    }
});

It is then impossible to use this action twice for two different buttons because it can't be copied, e.g.

element! {
    Fragment {
        Button(handler: move |_| action(Action::ToggleFoo)) { Text(content: "[Foo]") }
        Button(handler: move |_| action(Action::ToggleBar)) { Text(content: "[Bar]") }
    }
}

Making a special RefHandler that can be cloned resolve this issue (which is even easier with the new RefHandler::bind function). And implementing impl From<RefHandler> for Handler introduces no breaking changes.

RefHandler might be a wrong name ? I was considering AsyncHandler or ConstHandler, but RefHandler seemed more appropriate with the rest of the lib's naming ? I'm open to changing it.

Related Issues

None.

@codecov
Copy link

codecov bot commented Oct 12, 2025

Codecov Report

❌ Patch coverage is 92.30769% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.75%. Comparing base (48518db) to head (55fddc7).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
packages/iocraft/src/handler.rs 91.66% 3 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main     #146   +/-   ##
=======================================
  Coverage   89.74%   89.75%           
=======================================
  Files          31       31           
  Lines        5139     5171   +32     
  Branches     5139     5171   +32     
=======================================
+ Hits         4612     4641   +29     
- Misses        427      430    +3     
  Partials      100      100           
Files with missing lines Coverage Δ
packages/iocraft/src/components/button.rs 91.48% <ø> (ø)
packages/iocraft/src/components/text_input.rs 78.37% <ø> (ø)
packages/iocraft/src/hooks/use_async_handler.rs 93.02% <100.00%> (ø)
packages/iocraft/src/handler.rs 90.32% <91.66%> (+0.32%) ⬆️

Impacted file tree graph

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ccbrown ccbrown added the enhancement New feature or request label Oct 14, 2025
@ccbrown
Copy link
Owner

ccbrown commented Oct 14, 2025

Thanks for the PR! I like the idea, but I do think it might be a bit confusing.

You changed use_async_handler – do you think that the majority of use-cases for use_async_handler would be better served with this over Handler? Are there use-cases that would be negatively impacted by changing that return type?

I wonder if having two functions, e.g. use_async_handler and use_async_handler_mut, would make sense?

@barsoosayque
Copy link
Contributor Author

You changed use_async_handler – do you think that the majority of use-cases for use_async_handler would be better served with this over Handler? Are there use-cases that would be negatively impacted by changing that return type?

Currently yeah, I think so. As far as I understand, there are two key differences here:

1. Handler can capture local mutable references

let mut counter = hooks.use_state(|| 0);
let mut hook = Handler::from(|_| counter += 1); // note that there is no `move`

// counter.read(); // Can't read `counter` -- borrowed as mut in `hook`
hook(());

But in reality, in iocraft handlers are usually bound to 'static:

Which ultimately means that throughout the code, captured values in Handlers are 'static captures anyways (cloned State/Refs ?). To use the example above, iocraft suggest to move values into Handlers:

let mut counter = hooks.use_state(|| 0);
let mut hook = Handler::<'static, _>::from(move |_| counter += 1);

counter.read();
hook(());

But it is still possible to use Handlers for some sort of local closure wrappers ! Even though it seems that it's not used in iocraft itself ?

2. Handler mutate captures on call, RefHandler mutate captures in the returned async block

By definition of rust's closure types, FnMut is a closure that directly mutates it's inner state, which Handle absolutely does: Handler::from(move |_| var.set(true)) mutates var even though it's moved into the closure. But it's different for hooks.use_async_handler(...) because even though it too uses closures, the actual logic happens in futures, returned by said closures:

iocraft/examples/weather.rs

Lines 214 to 217 in 48518db

let mut load = hooks.use_async_handler(move |_: ()| async move {
state.set(WeatherState::Loading);
state.set(WeatherState::Loaded(WeatherData::fetch().await));
});

Here, we can see that the closure clones state into itself (because of the move |_: ()| ...), but it does not mutate anything, it just returns an async block, which clones state once again into itself (by async move) ! And only in the async block state is mutated -- the actual closure doesn't have to be FnMut in that case, because it's a simple future generator, which just re-clones the internal state and passes it to the new future.

Note that prior to this PR it was possible to mutate captures in the use_async_handler generator closure:

let mut counter = hooks.use_state(|| 0);
let mut hook = hooks.use_async_handler(move |_:()| {
    counter.set(10); // mutate `counter` in the closure
    async move { 
        counter += 1; // mutate `counter` in the returned future
    }
});

But that seems to be both conceptually wrong (since the closure is used to only generate futures) and easily fixed in the new RefHandler: just move everything inside the async block.


Though I'm bad at esoterics of async rust, I think there should not be cases that were possible before but are impossible with this PR ? I do agree that it's super confusing though.

@ccbrown
Copy link
Owner

ccbrown commented Oct 22, 2025

Okay, I'm convinced that this change is the right thing to do. Let me just bikeshed over the name for a bit. To me, I think it would be slightly more intuitive if the types were...

pub struct Handler<T>(bool, Arc<dyn Fn(T) + Send + Sync + 'static>);
pub struct HandlerMut<'a, T>(bool, Box<dyn FnMut(T) + Send + Sync + 'a>);

This makes it more of a breaking change, but I think it's justified.

@barsoosayque barsoosayque changed the title feat: add RefHandler feat: add clonable immutable Handler Oct 23, 2025
@barsoosayque
Copy link
Contributor Author

Ok, that's more in line with rust stdlib naming (Deref/DerefMut)

Copy link
Owner

@ccbrown ccbrown left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for being so slow to respond on this one. I'm just trying to give myself time to think of any other hidden gotchas here. But I think it looks good. Just one more comment on some ways the docs could possibly be improved. Let me know what you think.

Thanks so much for this!

}
}

/// Immutable variant of [`HandlerMut`]: it lacks ability to mutate captured variables, but can be cloned.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's important to point out that this can be turned into HandlerMut. Maybe say something like...

Just as Fn can be used where an FnMut is expected, Handler can be used where a HandlerMut is expected via From/Into.

Also, it is probably worth pointing out that components should usually accept properties via HandlerMut instead of Handler.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried my best at writing docs for Handler, and also included an example. It still is a bit confusing, but at least there is a clear and communicated rationale for the distinction.

@ccbrown ccbrown merged commit 32bbd78 into ccbrown:main Oct 28, 2025
5 checks passed
@ccbrown
Copy link
Owner

ccbrown commented Oct 28, 2025

Thanks again for this! 🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants