Skip to content

Domain Events as a First-Class Concept #9

Description

@BinaryMuse

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions