Middleware
Middleware intercepts actions before and after they reach the reducer, enabling cross-cutting concerns like logging, analytics, throttling, and debugging without modifying your reducer logic.
The Middleware Trait
pub trait Middleware<S, A: Action> { /// Called before the action is dispatched to the reducer. /// Return `true` to proceed, `false` to cancel the action. fn before(&mut self, action: &A, state: &S) -> bool;
/// Called after the reducer processes the action. /// Return follow-up actions to dispatch through the full pipeline. fn after(&mut self, action: &A, state_changed: bool, state: &S) -> Vec<A>;}Middleware receives a read-only reference to the store’s state both before and after dispatch.
Cancel
Returning false from before() prevents the action from reaching the reducer entirely. Use this for throttling, rate limiting, or conditional gating.
Inject
Returning actions from after() dispatches them through the full middleware → reducer → middleware pipeline. This enables derived actions, automatic follow-ups, and cascade patterns.
The dispatch flow with middleware:
action → middleware.before(&state) → false? → cancelled → true? → reducer() → middleware.after(&state) → result ↓ injected actions ↓ full pipeline (recursive)Middleware dispatch is protected by configurable DispatchLimits (defaults: max_depth = 16, max_actions = 10_000) to prevent runaway loops. Both limits should be at least 1. The action budget counts attempted dispatches, including actions cancelled by before().
Using StoreWithMiddleware
Wrap your store with middleware using StoreWithMiddleware:
use tui_dispatch::prelude::*;
let middleware = LoggingMiddleware::new();let mut store = StoreWithMiddleware::new(AppState::default(), reducer, middleware) .with_dispatch_limits(DispatchLimits { max_depth: 64, max_actions: 10_000, });
// Non-panicking path with typed errorsmatch store.try_dispatch(Action::Increment) { Ok(changed) => { if changed { // render } } Err(err) => { // log / telemetry / fallback eprintln!("dispatch failed: {err}"); }}dispatch(...) remains available for compatibility and wraps try_dispatch(...); it panics if dispatch limits are exceeded.
try_dispatch(...) is not transactional: if an injected chain overflows, earlier actions may already have mutated state.
Built-in Middleware
LoggingMiddleware
Logs actions via the tracing crate:
use tui_dispatch::LoggingMiddleware;
// Log after dispatch only (default)let middleware = LoggingMiddleware::new();
// Log both before and afterlet middleware = LoggingMiddleware::verbose();Output (at debug level):
DEBUG action dispatched: Increment (changed: true)NoopMiddleware
A no-op middleware for type-level plumbing when you need the StoreWithMiddleware type but don’t want any middleware behavior:
use tui_dispatch::NoopMiddleware;
let store = StoreWithMiddleware::new(state, reducer, NoopMiddleware);ComposedMiddleware
Chain multiple middleware together:
use tui_dispatch::{ComposedMiddleware, LoggingMiddleware};
let mut composed = ComposedMiddleware::new();composed.add(LoggingMiddleware::new());composed.add(MyCustomMiddleware::new());
let store = StoreWithMiddleware::new(state, reducer, composed);Execution order:
before(): called in order (first added → last added). Short-circuits if any returnsfalse.after(): called in reverse order (last added → first added). All injected actions are collected.
This ensures proper nesting (like try/finally blocks).
ActionLoggerMiddleware
The debug crate provides ActionLoggerMiddleware for detailed action logging with pattern filtering and in-memory storage.
use tui_dispatch::debug::ActionLoggerMiddleware;
// Default: filters out Tick and Render actionslet middleware = ActionLoggerMiddleware::default_filtering();
// Log everythinglet middleware = ActionLoggerMiddleware::log_all();
// With in-memory ring buffer (for debug overlay)let middleware = ActionLoggerMiddleware::with_default_log();Pattern Filtering
Filter actions using glob patterns:
use tui_dispatch::debug::{ActionLoggerConfig, ActionLoggerMiddleware};
let config = ActionLoggerConfig::new( Some("Search*,User*"), // Include: only these patterns Some("*Tick,*Render"), // Exclude: filter these out);
let middleware = ActionLoggerMiddleware::new(config);You can combine filter types:
let config = ActionLoggerConfig::new( Some("cat:search,name:SearchSubmit"), // Include search category + one exact action Some("name:SearchCancel"), // Exclude one action explicitly);Pattern syntax:
*matches zero or more characters?matches exactly one characterSearch*matchesSearchStart,SearchClear,SearchSubmit*DidLoadmatchesUserDidLoad,WeatherDidLoadcat:searchmatches actions in inferredsearchcategorycat:search_*supports category globsname:WeatherDidLoadmatches one exact action name (no wildcards)
Notes:
name:is exact-match only and case-sensitive. Use plain glob patterns (for exampleWeatherDid*) for wildcard name matching.cat:uses inferred action categories, which are strongest with NounVerb naming (SearchStart,WeatherDidLoad). VerbNoun names (OpenFile) typically do not infer categories.- Inferred category values are lowercase (for example
search_query), so category filter patterns should also be lowercase. - Single-word action names (for example
Tick) usually have no inferred category, socat:tickwill not match them. Use name/glob filters instead.
Accessing the Action Log
When using with_default_log() or with_log(), you can access recorded actions:
let middleware = ActionLoggerMiddleware::with_default_log();
// After some dispatches...if let Some(log) = middleware.log() { for entry in log.entries() { println!("{}: {}", entry.elapsed, entry.name, ); }
// Get recent entries for entry in log.recent(5) { // Last 5 actions }}Integration with Debug Layer
The debug layer can display the action log in an overlay:
use tui_dispatch::debug::{ActionLoggerMiddleware, DebugLayer};
let middleware = ActionLoggerMiddleware::with_default_log();let debug = DebugLayer::simple();
// In your render loop, the debug layer can show the action log// Press 'A' when debug is enabled to toggle the action log overlayWriting Custom Middleware
Implement the Middleware trait for custom behavior:
use tui_dispatch::{Action, Middleware};use std::time::Instant;
struct TimingMiddleware { start: Option<Instant>,}
impl TimingMiddleware { fn new() -> Self { Self { start: None } }}
impl<S, A: Action> Middleware<S, A> for TimingMiddleware { fn before(&mut self, action: &A, _state: &S) -> bool { self.start = Some(Instant::now()); true }
fn after(&mut self, action: &A, state_changed: bool, _state: &S) -> Vec<A> { if let Some(start) = self.start.take() { let elapsed = start.elapsed(); if elapsed.as_millis() > 10 { tracing::warn!( "Slow action: {} took {:?}", action.name(), elapsed ); } } vec![] }}Cancelling Actions
Use before() returning false to prevent actions from reaching the reducer:
struct ThrottleMiddleware { last_dispatch: Option<Instant>, min_interval: Duration,}
impl<S, A: Action> Middleware<S, A> for ThrottleMiddleware { fn before(&mut self, _action: &A, _state: &S) -> bool { let now = Instant::now(); if let Some(last) = self.last_dispatch { if now.duration_since(last) < self.min_interval { return false; // Too soon — cancel } } self.last_dispatch = Some(now); true }
fn after(&mut self, _action: &A, _state_changed: bool, _state: &S) -> Vec<A> { vec![] }}Injecting Follow-Up Actions
Use after() to inject actions that go through the full pipeline:
struct AutoSaveMiddleware { dirty_count: u32, save_threshold: u32,}
impl<S, A: Action> Middleware<S, A> for AutoSaveMiddlewarewhere A: From<SaveAction>,{ fn before(&mut self, _action: &A, _state: &S) -> bool { true }
fn after(&mut self, _action: &A, state_changed: bool, _state: &S) -> Vec<A> { if state_changed { self.dirty_count += 1; if self.dirty_count >= self.save_threshold { self.dirty_count = 0; return vec![SaveAction::AutoSave.into()]; } } vec![] }}Middleware with State
Middleware can maintain state across dispatches:
struct MetricsMiddleware { action_counts: HashMap<&'static str, u64>, total_dispatches: u64,}
impl<S, A: Action> Middleware<S, A> for MetricsMiddleware { fn before(&mut self, _action: &A, _state: &S) -> bool { true }
fn after(&mut self, action: &A, _state_changed: bool, _state: &S) -> Vec<A> { self.total_dispatches += 1; *self.action_counts.entry(action.name()).or_insert(0) += 1; vec![] }}Middleware with Effects
For EffectStore, use EffectStoreWithMiddleware:
use tui_dispatch::prelude::*;
let middleware = LoggingMiddleware::new();let mut store = EffectStoreWithMiddleware::new(state, reducer, middleware);
let result = store.dispatch(action);// result.changed: bool// result.effects: Vec<Effect>The middleware sees the action but not the effects. Effects are handled separately in your effect handler. Injected actions from after() have their effects merged into the returned result.
See Also
- Debug Layer - Interactive debugging with action log overlay
- Debug Sessions - Recording and replaying actions