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