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}