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}