Skip to content

Component Host

ComponentHost<S, A, Id, Ctx> is an optional Layer 2 helper for long-lived interactive widgets. It wraps the Layer 1 runtime additively through RuntimeHostExt::with_component_host(...)tui-dispatch-core stays component-agnostic.

It solves two related problems:

  • where reusable widget instances live
  • how to avoid rebuilding the same props in both event wiring and render wiring

The host owns widget instances and their props factories. Your store still owns app state.

When to Use It

Reach for ComponentHost when:

  • you have multiple focusable widgets
  • those widgets keep local UI state across frames
  • the same props logic is repeated in render and event handlers
  • you want optional EventBus integration without storing widgets ad hoc in UI state

If you do not have that pressure yet, stay with direct InteractiveComponent::update(...) calls.

The Core Idea

Mount a widget once:

use std::rc::Rc;
use tui_dispatch_components::{PropsFactory, TextInputCallback};
struct SearchPropsFactory {
on_change: TextInputCallback<Action>,
on_submit: TextInputCallback<Action>,
}
impl PropsFactory<AppState, TextInput, Action, AppBindingContext> for SearchPropsFactory {
fn props<'a>(&self, state: &'a AppState) -> TextInputProps<'a, Action> {
TextInputProps {
value: &state.query,
placeholder: "Search",
is_focused: state.focus == Focus::Search,
style: TextInputStyle::default(),
on_change: self.on_change.clone(),
on_submit: self.on_submit.clone(),
on_cursor_move: None,
on_cancel: None,
}
}
}
let host = ComponentHost::<AppState, Action, RouteId, AppBindingContext>::new();
let search = host.mount(
TextInput::new,
SearchPropsFactory {
on_change: Rc::new(Action::QueryChanged),
on_submit: Rc::new(|_| Action::SubmitSearch),
},
);

After that, the same stored props factory is reused for:

  • host.update(...)
  • host.render(...)

That is the main ergonomics win.

EventBus Integration

The host can optionally bind mounted widgets to EventBus routes:

let host = ComponentHost::<AppState, Action, RouteId, AppBindingContext>::new();
let mounted = MountedViews {
levels: host.mount(FilterPane::new, level_filter_props),
tags: host.mount(FilterPane::new, tag_filter_props),
logs: host.mount(SelectList::new, log_list_props),
details: host.mount(LogDetails::new, details_props),
};
let mut bus = EventBus::<AppState, Action, RouteId, AppBindingContext>::new();
host.bind(&mut bus, RouteId::Levels, mounted.levels);
host.bind(&mut bus, RouteId::Tags, mounted.tags);
host.bind(&mut bus, RouteId::Logs, mounted.logs);
host.bind(&mut bus, RouteId::Details, mounted.details);

bind(...) registers the widget handler and subscribes it to the event types declared by InteractiveComponent::subscriptions(). Widgets default to key events; override the associated function when a component needs broadcast-style delivery such as ticks or resizes.

Render stays explicit:

terminal.draw(|frame| {
host.render(mounted.levels, frame, levels_area, store.state());
host.render(mounted.tags, frame, tags_area, store.state());
host.render(mounted.logs, frame, logs_area, store.state());
host.render(mounted.details, frame, details_area, store.state());
})?;
host.sync_areas(&mut bus);

sync_areas(...) keeps EventBus hit-testing and routing context aligned with the widgets rendered this frame.

Wiring into the runtime

When the runtime owns the main loop, attach the host after adding the event bus:

use tui_dispatch_components::RuntimeHostExt;
let mut runtime = Runtime::new(state, reducer)
.with_event_bus(bus, keybindings)
.with_component_host(host.clone());
runtime
.run(
terminal,
render,
is_quit,
)
.await?;

This is the recommended Layer 2 integration path: ComponentHost lives in tui-dispatch-components and uses the Layer 1 post-render hook internally, so tui-dispatch-core stays component-agnostic. If you still need your own post-render hook, call run_with_hooks(...) on the hosted runtime; it runs host.sync_areas(...) first, then your hook.

Without EventBus

ComponentHost is still useful if you want mounted widgets but do not want bus routing:

let response = host.update(search, ComponentInput::Key(key_event), store.state());
for action in response.actions {
store.dispatch(action);
}

That keeps the host aligned with the layered architecture instead of making EventBus mandatory.

Ownership Rules

The architecture is:

  • store owns app state
  • host owns mounted widget instances and widget-local runtime state
  • EventBus optionally routes into the host

For ids managed through host.bind(...), treat the host as the authoritative owner of that binding.

In other words: do not manually re-register the same route on the bus behind the host’s back.

Lifecycle

Important host operations:

  • mount(...)
  • bind(...)
  • unbind(...)
  • unmount(...)
  • reset_local_state(...)
  • mounted_components(...)

unmount(...) intentionally refuses to remove a still-bound widget. Unbind it first.

That keeps cleanup explicit instead of leaving stale routing behind.

Debug and Replay Semantics

Widget-local state inside the host is runtime state.

That means:

  • it can be inspected through mounted_components()
  • it can be reset through reset_local_state()
  • it is not reconstructed automatically by action-only replay

The canonical replay/debug guarantee remains app state plus action log.

When This Layer Is Worth It

ComponentHost is worth the complexity when it deletes real boilerplate from a larger app.

Typical signs:

  • widgets were being stored ad hoc in UI state
  • props were built once for input and again for render
  • EventBus registration code started to dominate main.rs

If none of that is happening yet, stay on the simpler layers.