Skip to content

Domain Events as a First-Class Concept #9

@BinaryMuse

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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions