Core Concepts
If you’re familiar with Redux or The Elm Architecture, many concepts will feel familiar.
Core
The bare minimum to build an app:
Action
A description of something that happened or should happen. Actions are immutable, cloneable values sent to the store for processing. (See also: Redux Actions)
#[derive(Clone, Debug, Action)]enum AppAction { CountIncrement, CountDecrement, WeatherFetch, WeatherDidLoad(WeatherData), Quit,}Intent actions trigger work: WeatherFetch, SearchStart
Result actions report outcomes: WeatherDidLoad, SearchDidComplete
State
The application’s data. A plain Rust struct holding everything the app “knows”.
#[derive(Default)]struct AppState { count: i32, weather: Option<WeatherData>, is_loading: bool,}Reducer
A pure function that takes current state and an action, mutates the state, and returns a ReducerResult. (See also: Redux Reducers)
Reducers without effects use the default ReducerResult type:
fn reducer(state: &mut AppState, action: AppAction) -> ReducerResult { match action { AppAction::CountIncrement => { state.count += 1; ReducerResult::changed() } AppAction::Quit => ReducerResult::unchanged(), }}Reducers that emit effects name the effect type:
fn reducer(state: &mut AppState, action: AppAction) -> ReducerResult<Effect> { match action { AppAction::WeatherFetch => { state.is_loading = true; ReducerResult::changed_with(Effect::FetchWeather) } AppAction::WeatherDidLoad(data) => { state.weather = Some(data); state.is_loading = false; ReducerResult::changed() } }}Store
Container that holds state and applies the reducer when actions are dispatched. (See also: Redux Store)
let mut store = Store::new(AppState::default(), reducer);let result = store.dispatch(AppAction::CountIncrement);assert!(result.changed);Dispatch
The act of sending an action to the store for processing. (See also: Redux Data Flow)
store.dispatch(action); // Sync dispatchaction_tx.send(action); // Async dispatch via channelExtensions
Add these when your app needs them. None are required.
Runtime
Event/action/render loop helper so apps don’t have to hand-write the loop. Runtime<S, A> is the no-effect runtime shape; Runtime<S, A, E> runs reducers that can emit effects.
Optional capabilities are attached additively with with_* builders. with_event_bus(bus, keybindings) keeps the value as a Runtime and routes events through the bus.
let mut runtime = Runtime::new(state, reducer) .with_debug(debug) .with_event_bus(bus, keybindings);
runtime.run(terminal, render, is_quit).await?;run_with_hooks(...) is the post-render integration seam wrappers consume when they need to touch the bus after each frame. ComponentHost users normally get this through RuntimeHostExt::with_component_host(...).
Apps can skip the runtime helpers entirely and drive Store from a custom loop — Layer 0 stays fully usable on its own.
Effect
For apps with async operations. A declarative description of a side effect, returned from the reducer as data. The main loop executes effects outside the reducer. This pattern comes from The Elm Architecture where commands describe what to do without doing it.
enum Effect { FetchWeather { lat: f64, lon: f64 }, CopyToClipboard(String), SaveFile(PathBuf),}ReducerResult
The return type of an effect reducer. Contains whether state changed and any effects to execute.
ReducerResult::unchanged() // No change, no effectsReducerResult::changed() // State changed, no effectsReducerResult::effect(e) // No change, one effectReducerResult::changed_with(e) // State changed, one effectReducerResult::changed_with_many(v) // State changed, multiple effectsThe same Store type works for reducers with and without effects:
let mut store = Store::new(AppState::default(), reducer);let result = store.dispatch(action);// result.changed: bool// result.effects: Vec<Effect>EventBus
For apps with multiple focusable components. Routes input based on focus state (modal → hovered → focused → subscribers → global) and resolves keybindings.
Requires two app-level types: ComponentId to represent focusable targets, and EventRoutingState to read focus from your state.
#[derive(ComponentId, Clone, Copy, PartialEq, Eq, Hash, Debug)]enum AppComponentId { Main }
impl EventRoutingState<AppComponentId, DefaultBindingContext> for AppState { fn focused(&self) -> Option<AppComponentId> { Some(AppComponentId::Main) } fn modal(&self) -> Option<AppComponentId> { None } fn binding_context(&self, _id: AppComponentId) -> DefaultBindingContext { DefaultBindingContext } fn default_context(&self) -> DefaultBindingContext { DefaultBindingContext }}
let mut bus: SimpleEventBus<AppState, Action, AppComponentId> = SimpleEventBus::new();bus.register(AppComponentId::Main, |event, state| { let actions = handle_event(&event.kind, state); if actions.is_empty() { HandlerResponse::ignored() } else { HandlerResponse::actions(actions) }});See Event Bus for the full guide.
TaskManager
For async operations. Manages one-shot tasks with automatic cancellation. Requires features = ["tasks"].
let mut tasks = TaskManager::new(action_tx);tasks.spawn("weather", async move { Action::DidLoad(api::fetch().await) });tasks.debounce("search", Duration::from_millis(200), async move { ... });Subscriptions
For continuous action sources like timers and streams. Requires features = ["subscriptions"].
let mut subs = Subscriptions::new(action_tx);subs.interval("tick", Duration::from_millis(100), || Action::Tick);subs.stream("events", event_stream.map(Action::Event));Component
The minimal reusable UI contract. A struct that renders from props and can emit actions from raw EventKind.
impl Component<AppAction> for Counter { type Props<'a> = &'a AppState;
fn handle_event(&mut self, event: &EventKind, props: Self::Props<'_>) -> impl IntoIterator<Item = AppAction> { match event { EventKind::Key(key) if key.code == KeyCode::Up => Some(AppAction::Increment), _ => None, } }
fn render(&mut self, frame: &mut Frame, area: Rect, props: Self::Props<'_>) { // render UI }}This is the simplest Layer 2 tool. For the full decision guide, see Components Overview.
InteractiveComponent
For reusable widgets with local UI state that need routed input and explicit render hints.
Interactive widgets receive ComponentInput and return HandlerResponse<A>, so they can express:
- semantic commands like
next,prev,select - raw key input for text entry
needs_renderwhen local widget state changes without app-state changes
See Interactive Widgets.
ComponentHost
For long-lived mounted widget instances and one-time props wiring.
ComponentHost owns interactive widget instances and can optionally bind them into EventBus, which keeps widget-local runtime state out of app state while still preserving store-centric architecture.
See Component Host.
RenderContext
Context passed to render functions, providing access to debug state.
fn render_app(frame: &mut Frame, area: Rect, state: &AppState, ctx: RenderContext) { if ctx.debug_enabled { /* show debug info */ }}Keybindings
For user-configurable key mappings. A shared configuration that maps keys to command names, scoped by context.
let mut bindings = Keybindings::new();bindings.add_global("quit", vec!["q".to_string()]);bindings.add(Context::Search, "submit", vec!["enter".to_string()]);See Keybindings for the full guide.
Derive Macros
#[derive(Action)]
Generates the Action trait implementation. Required for all action enums.
#[derive(Clone, Debug, Action)]enum AppAction { ... }#[action(infer_categories)]
Auto-generates category methods based on action variant name prefixes.
#[derive(Action)]#[action(infer_categories)]enum Action { SearchStart, // category: "search", is_search() = true SearchClear, // category: "search", is_search() = true DidLoadData, // category: "async_result", is_async_result() = true Quit, // category: None}Categories enable reducer_compose! for routing actions by category.
#[action(category = "...")] (variant attribute)
Explicitly set the category for a specific variant, overriding inference:
#[derive(Action)]#[action(infer_categories)]enum Action { // Inferred: "search" SearchStart,
// Override: "network" instead of inferred "api" #[action(category = "network")] ApiFetch,
// Override: "network" (no prefix to infer from) #[action(category = "network")] Reconnect,}#[action(skip_category)] (variant attribute)
Exclude a variant from category inference entirely:
#[derive(Action)]#[action(infer_categories)]enum Action { SearchStart, // category: "search"
#[action(skip_category)] InternalTick, // category: None (not categorized)}#[derive(DebugState)]
Auto-generates debug overlay sections for state inspection.
#[derive(DebugState)]struct AppState { #[debug(section = "Connection")] host: String,
#[debug(skip)] cache: HashMap<String, Data>,}Data Flow
Core flow (no extensions):
Terminal Input → map to Action → store.dispatch(action) → reducer → render if changedWith effects:
reducer returns ReducerResult { changed, effects } → render if changed → for each effect: spawn async task → send result action back to dispatchWith EventBus:
Terminal Input → EventBus routes to focused handler → handler returns actions → dispatch