-
Notifications
You must be signed in to change notification settings - Fork 2
Domain Events as a First-Class Concept #9
Description
Problem
Today, domain events (component signals something application-specific) are handled via callback props or context-injected channels. In atuin, components capture an mpsc::Sender<AiTuiEvent> from context in their lifecycle() hook, store it in state, then .send() events through it in event handlers. This works but is boilerplate-heavy:
// Every component that emits domain events:
struct MyComponentState {
tx: Option<mpsc::Sender<AppEvent>>, // stored from context
}
fn lifecycle(&self, hooks: &mut Hooks<Self::State>, _state: &Self::State) {
hooks.use_context::<mpsc::Sender<AppEvent>>(|tx, state| {
state.tx = tx.cloned();
});
}
fn handle_event(&self, event: &Event, state: &mut Tracked<Self::State>) -> EventResult {
// ... match event ...
if let Some(ref tx) = state.read().tx {
let _ = tx.send(AppEvent::Something);
}
EventResult::Consumed
}This pattern repeats in every component that needs to communicate upward. The DESIGN.md currently says "callback props first, context when drilling becomes painful" — but in practice, context channels became the go-to pattern in atuin immediately.
Directions to consider
Callback props (current recommendation):
struct InputBox {
on_submit: Option<Box<dyn Fn(String) + Send + Sync>>,
}Pro: simple, traceable, no framework support needed. Con: verbose type signatures, prop drilling through intermediate components, closures that capture Handle are awkward.
Framework-integrated event emission: What if the framework understood "this component emits events of type T" and provided sugar for it?
// Hypothetical
impl Component for InputBox {
type Event = InputEvent; // associated type
fn handle_event(&self, event: &Event, state: &mut Tracked<Self::State>) -> EventResult {
// ...
self.emit(InputEvent::Submit(text)); // framework-provided
EventResult::Consumed
}
}
// Parent handles it:
element! {
InputBox(on: |event: InputEvent| { /* ... */ })
}Pro: eliminates channel boilerplate, type-safe. Con: adds complexity to Component trait and element system. How does
this compose through intermediate components?
Typed event bubbling:
Domain events bubble up through the tree (like terminal events do today), with parents optionally intercepting them:
// Child emits
self.emit(InputEvent::Submit(text));
// Any ancestor can handle
fn handle_domain_event(&self, event: &dyn Any, state: &mut Tracked<Self::State>) -> EventResult {
if let Some(input_event) = event.downcast_ref::<InputEvent>() {
// handle it
EventResult::Consumed
} else {
EventResult::Ignored // let it bubble
}
}Pro: no prop drilling, no context setup, natural tree-based routing. Con: type erasure, harder to trace, similar debuggability concerns as event buses (which DESIGN.md explicitly avoids).
Enhanced context pattern: Keep the context-channel approach but reduce boilerplate with framework support:
// Framework provides a typed event emitter
fn lifecycle(&self, hooks: &mut Hooks<Self::State>, _state: &Self::State) {
hooks.use_emitter::<AppEvent>(); // auto-captures from context
}
// In event handler, emitter available directly:
fn handle_event(&self, event: &Event, state: &mut Tracked<Self::State>) -> EventResult {
state.emit(AppEvent::Something); // sugar over channel send
EventResult::Consumed
}This is a lighter touch — just reducing the boilerplate around the pattern that already works, rather than introducing a new event model.
Direction: typed events on #[component]
The function component model (section 5) offers a clean answer: event = MyEvent on the #[component] attribute, with ctx.emit() in hooks. The framework routes events upward — parents handle them via the element tree, not via context channels or callback props.
This is the "framework-integrated event emission" option, but cleaner because the function component model gives us a natural place to declare the event type and a ctx to emit through.