Skip to main content

tui_dispatch_core/
effect.rs

1//! Effect-based state management
2//!
3//! This module provides an effect-aware store that allows reducers to emit
4//! side effects alongside state changes. Effects are declarative descriptions
5//! of work to be done, not the work itself.
6//!
7//! # Overview
8//!
9//! The traditional reducer returns `bool` (state changed or not):
10//! ```text
11//! fn reducer(state: &mut S, action: A) -> bool
12//! ```
13//!
14//! An effect-aware reducer returns both change status and effects:
15//! ```text
16//! fn reducer(state: &mut S, action: A) -> ReducerResult<E>
17//! ```
18//!
19//! # Example
20//!
21//! ```
22//! use tui_dispatch_core::{Action, ReducerResult, EffectStore};
23//!
24//! // Define your effects
25//! enum Effect {
26//!     FetchData { url: String },
27//!     CopyToClipboard(String),
28//! }
29//!
30//! // Define state and actions
31//! struct AppState { loading: bool, data: Option<String> }
32//!
33//! #[derive(Clone, Debug)]
34//! enum AppAction { LoadData, DidLoadData(String) }
35//!
36//! impl Action for AppAction {
37//!     fn name(&self) -> &'static str {
38//!         match self {
39//!             AppAction::LoadData => "LoadData",
40//!             AppAction::DidLoadData(_) => "DidLoadData",
41//!         }
42//!     }
43//! }
44//!
45//! // Reducer emits effects
46//! fn reducer(state: &mut AppState, action: AppAction) -> ReducerResult<Effect> {
47//!     match action {
48//!         AppAction::LoadData => {
49//!             state.loading = true;
50//!             ReducerResult::changed_with(
51//!                 Effect::FetchData { url: "https://api.example.com".into() }
52//!             )
53//!         }
54//!         AppAction::DidLoadData(data) => {
55//!             state.loading = false;
56//!             state.data = Some(data);
57//!             ReducerResult::changed()
58//!         }
59//!     }
60//! }
61//!
62//! // Main loop handles effects
63//! let mut store = EffectStore::new(
64//!     AppState { loading: false, data: None },
65//!     reducer,
66//! );
67//! let result = store.dispatch(AppAction::LoadData);
68//! assert!(result.changed);
69//!
70//! for effect in result.effects {
71//!     match effect {
72//!         Effect::FetchData { url } => { /* spawn async task */ }
73//!         _ => {}
74//!     }
75//! }
76//! ```
77
78use std::marker::PhantomData;
79
80use crate::action::Action;
81use crate::store::Middleware;
82
83/// Result of dispatching an action to an effect-aware store.
84///
85/// Contains both the state change indicator and any effects to be processed.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct ReducerResult<E> {
88    /// Whether the state was modified by this action.
89    pub changed: bool,
90    /// Effects to be processed after dispatch.
91    pub effects: Vec<E>,
92}
93
94impl<E> Default for ReducerResult<E> {
95    fn default() -> Self {
96        Self::unchanged()
97    }
98}
99
100impl<E> ReducerResult<E> {
101    /// Create a result indicating no state change and no effects.
102    #[inline]
103    pub fn unchanged() -> Self {
104        Self {
105            changed: false,
106            effects: vec![],
107        }
108    }
109
110    /// Create a result indicating state changed but no effects.
111    #[inline]
112    pub fn changed() -> Self {
113        Self {
114            changed: true,
115            effects: vec![],
116        }
117    }
118
119    /// Create a result with a single effect but no state change.
120    #[inline]
121    pub fn effect(effect: E) -> Self {
122        Self {
123            changed: false,
124            effects: vec![effect],
125        }
126    }
127
128    /// Create a result with multiple effects but no state change.
129    #[inline]
130    pub fn effects(effects: Vec<E>) -> Self {
131        Self {
132            changed: false,
133            effects,
134        }
135    }
136
137    /// Create a result indicating state changed with a single effect.
138    #[inline]
139    pub fn changed_with(effect: E) -> Self {
140        Self {
141            changed: true,
142            effects: vec![effect],
143        }
144    }
145
146    /// Create a result indicating state changed with multiple effects.
147    #[inline]
148    pub fn changed_with_many(effects: Vec<E>) -> Self {
149        Self {
150            changed: true,
151            effects,
152        }
153    }
154
155    /// Add an effect to this result.
156    #[inline]
157    pub fn with(mut self, effect: E) -> Self {
158        self.effects.push(effect);
159        self
160    }
161
162    /// Set the changed flag to true.
163    #[inline]
164    pub fn mark_changed(mut self) -> Self {
165        self.changed = true;
166        self
167    }
168
169    /// Returns true if there are any effects to process.
170    #[inline]
171    pub fn has_effects(&self) -> bool {
172        !self.effects.is_empty()
173    }
174}
175
176/// A reducer function that can emit effects.
177///
178/// Takes mutable state and an action, returns whether state changed
179/// and any effects to process.
180pub type EffectReducer<S, A, E> = fn(&mut S, A) -> ReducerResult<E>;
181
182/// A store that supports effect-emitting reducers.
183///
184/// Similar to [`Store`](crate::Store), but the reducer returns
185/// [`ReducerResult<E>`] instead of `bool`, allowing it to declare
186/// side effects alongside state changes.
187///
188/// # Example
189///
190/// ```
191/// use tui_dispatch_core::{Action, ReducerResult, EffectStore};
192///
193/// #[derive(Clone, Debug)]
194/// enum MyAction { Increment }
195///
196/// impl Action for MyAction {
197///     fn name(&self) -> &'static str { "Increment" }
198/// }
199///
200/// enum Effect { Log(String) }
201/// struct State { count: i32 }
202///
203/// fn reducer(state: &mut State, action: MyAction) -> ReducerResult<Effect> {
204///     match action {
205///         MyAction::Increment => {
206///             state.count += 1;
207///             ReducerResult::changed_with(Effect::Log(format!("count is {}", state.count)))
208///         }
209///     }
210/// }
211///
212/// let mut store = EffectStore::new(State { count: 0 }, reducer);
213/// let result = store.dispatch(MyAction::Increment);
214/// assert!(result.changed);
215/// assert_eq!(result.effects.len(), 1);
216/// ```
217pub struct EffectStore<S, A, E> {
218    state: S,
219    reducer: EffectReducer<S, A, E>,
220    _marker: PhantomData<(A, E)>,
221}
222
223impl<S, A, E> EffectStore<S, A, E>
224where
225    A: Action,
226{
227    /// Create a new effect store with the given initial state and reducer.
228    pub fn new(state: S, reducer: EffectReducer<S, A, E>) -> Self {
229        Self {
230            state,
231            reducer,
232            _marker: PhantomData,
233        }
234    }
235
236    /// Get a reference to the current state.
237    #[inline]
238    pub fn state(&self) -> &S {
239        &self.state
240    }
241
242    /// Get a mutable reference to the state.
243    ///
244    /// Use sparingly - prefer dispatching actions for state changes.
245    /// This is mainly useful for initialization.
246    #[inline]
247    pub fn state_mut(&mut self) -> &mut S {
248        &mut self.state
249    }
250
251    /// Dispatch an action to the store.
252    ///
253    /// The reducer is called with the current state and action,
254    /// returning whether state changed and any effects to process.
255    #[inline]
256    pub fn dispatch(&mut self, action: A) -> ReducerResult<E> {
257        (self.reducer)(&mut self.state, action)
258    }
259}
260
261/// An effect store with middleware support.
262///
263/// Wraps an [`EffectStore`] and calls middleware hooks before and after
264/// each dispatch. The middleware receives action references and the
265/// state change indicator, but not the effects.
266///
267/// # Example
268///
269/// ```ignore
270/// use tui_dispatch::{ReducerResult, EffectStoreWithMiddleware};
271/// use tui_dispatch::debug::ActionLoggerMiddleware;
272///
273/// let middleware = ActionLoggerMiddleware::with_default_log();
274/// let mut store = EffectStoreWithMiddleware::new(
275///     State::default(),
276///     reducer,
277///     middleware,
278/// );
279///
280/// let result = store.dispatch(Action::Something);
281/// // Middleware logged the action
282/// // result.effects contains any effects to process
283/// ```
284pub struct EffectStoreWithMiddleware<S, A, E, M>
285where
286    A: Action,
287    M: Middleware<S, A>,
288{
289    store: EffectStore<S, A, E>,
290    middleware: M,
291    dispatch_depth: usize,
292}
293
294impl<S, A, E, M> EffectStoreWithMiddleware<S, A, E, M>
295where
296    A: Action,
297    M: Middleware<S, A>,
298{
299    /// Create a new effect store with middleware.
300    pub fn new(state: S, reducer: EffectReducer<S, A, E>, middleware: M) -> Self {
301        Self {
302            store: EffectStore::new(state, reducer),
303            middleware,
304            dispatch_depth: 0,
305        }
306    }
307
308    /// Get a reference to the current state.
309    #[inline]
310    pub fn state(&self) -> &S {
311        self.store.state()
312    }
313
314    /// Get a mutable reference to the state.
315    #[inline]
316    pub fn state_mut(&mut self) -> &mut S {
317        self.store.state_mut()
318    }
319
320    /// Get a reference to the middleware.
321    #[inline]
322    pub fn middleware(&self) -> &M {
323        &self.middleware
324    }
325
326    /// Get a mutable reference to the middleware.
327    #[inline]
328    pub fn middleware_mut(&mut self) -> &mut M {
329        &mut self.middleware
330    }
331
332    /// Dispatch an action through middleware and store.
333    ///
334    /// The action passes through `middleware.before()` (which can cancel it),
335    /// then the reducer, then `middleware.after()` (which can inject follow-up actions).
336    /// Injected actions go through the full pipeline recursively; their effects
337    /// are merged into the returned result.
338    pub fn dispatch(&mut self, action: A) -> ReducerResult<E> {
339        use crate::store::MAX_DISPATCH_DEPTH;
340
341        self.dispatch_depth += 1;
342        assert!(
343            self.dispatch_depth <= MAX_DISPATCH_DEPTH,
344            "middleware dispatch depth exceeded {MAX_DISPATCH_DEPTH} — likely infinite injection loop"
345        );
346
347        if !self.middleware.before(&action, &self.store.state) {
348            self.dispatch_depth -= 1;
349            return ReducerResult::unchanged();
350        }
351
352        let mut result = self.store.dispatch(action.clone());
353        let injected = self
354            .middleware
355            .after(&action, result.changed, &self.store.state);
356
357        for a in injected {
358            let sub = self.dispatch(a);
359            result.changed |= sub.changed;
360            result.effects.extend(sub.effects);
361        }
362
363        self.dispatch_depth -= 1;
364        result
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[derive(Clone, Debug)]
373    enum TestAction {
374        Increment,
375        Decrement,
376        NoOp,
377        TriggerEffect,
378    }
379
380    impl Action for TestAction {
381        fn name(&self) -> &'static str {
382            match self {
383                TestAction::Increment => "Increment",
384                TestAction::Decrement => "Decrement",
385                TestAction::NoOp => "NoOp",
386                TestAction::TriggerEffect => "TriggerEffect",
387            }
388        }
389    }
390
391    #[derive(Debug, Clone, PartialEq)]
392    enum TestEffect {
393        Log(String),
394        Save,
395    }
396
397    #[derive(Default)]
398    struct TestState {
399        count: i32,
400    }
401
402    fn test_reducer(state: &mut TestState, action: TestAction) -> ReducerResult<TestEffect> {
403        match action {
404            TestAction::Increment => {
405                state.count += 1;
406                ReducerResult::changed()
407            }
408            TestAction::Decrement => {
409                state.count -= 1;
410                ReducerResult::changed_with(TestEffect::Log(format!("count: {}", state.count)))
411            }
412            TestAction::NoOp => ReducerResult::unchanged(),
413            TestAction::TriggerEffect => {
414                ReducerResult::effects(vec![TestEffect::Log("triggered".into()), TestEffect::Save])
415            }
416        }
417    }
418
419    #[test]
420    fn test_dispatch_result_builders() {
421        let r: ReducerResult<TestEffect> = ReducerResult::unchanged();
422        assert!(!r.changed);
423        assert!(r.effects.is_empty());
424
425        let r: ReducerResult<TestEffect> = ReducerResult::changed();
426        assert!(r.changed);
427        assert!(r.effects.is_empty());
428
429        let r = ReducerResult::effect(TestEffect::Save);
430        assert!(!r.changed);
431        assert_eq!(r.effects, vec![TestEffect::Save]);
432
433        let r = ReducerResult::changed_with(TestEffect::Save);
434        assert!(r.changed);
435        assert_eq!(r.effects, vec![TestEffect::Save]);
436
437        let r =
438            ReducerResult::changed_with_many(vec![TestEffect::Save, TestEffect::Log("x".into())]);
439        assert!(r.changed);
440        assert_eq!(r.effects.len(), 2);
441    }
442
443    #[test]
444    fn test_dispatch_result_chaining() {
445        let r: ReducerResult<TestEffect> = ReducerResult::unchanged()
446            .with(TestEffect::Save)
447            .mark_changed();
448        assert!(r.changed);
449        assert_eq!(r.effects, vec![TestEffect::Save]);
450    }
451
452    #[test]
453    fn test_effect_store_basic() {
454        let mut store = EffectStore::new(TestState::default(), test_reducer);
455
456        assert_eq!(store.state().count, 0);
457
458        let result = store.dispatch(TestAction::Increment);
459        assert!(result.changed);
460        assert!(result.effects.is_empty());
461        assert_eq!(store.state().count, 1);
462
463        let result = store.dispatch(TestAction::NoOp);
464        assert!(!result.changed);
465        assert_eq!(store.state().count, 1);
466    }
467
468    #[test]
469    fn test_effect_store_with_effects() {
470        let mut store = EffectStore::new(TestState::default(), test_reducer);
471
472        let result = store.dispatch(TestAction::Decrement);
473        assert!(result.changed);
474        assert_eq!(result.effects.len(), 1);
475        assert!(matches!(&result.effects[0], TestEffect::Log(s) if s == "count: -1"));
476
477        let result = store.dispatch(TestAction::TriggerEffect);
478        assert!(!result.changed);
479        assert_eq!(result.effects.len(), 2);
480    }
481
482    #[test]
483    fn test_effect_store_state_mut() {
484        let mut store = EffectStore::new(TestState::default(), test_reducer);
485        store.state_mut().count = 100;
486        assert_eq!(store.state().count, 100);
487    }
488
489    #[test]
490    fn test_has_effects() {
491        let r: ReducerResult<TestEffect> = ReducerResult::unchanged();
492        assert!(!r.has_effects());
493
494        let r = ReducerResult::effect(TestEffect::Save);
495        assert!(r.has_effects());
496    }
497}