FlowRedux Documentation

repository·main·Indexed 21 days ago

https://github.com/freeletics/flowredux

A Kotlin Multiplatform library for building asynchronous state machines using a DSL and Coroutines. FlowRedux simplifies complex application state management through state entries, dispatched actions, and a specialized DSL featuring components like inState, onEnter, and collectWhileInState. It includes integrations for AndroidX ViewModel and Jetpack Compose via the produceStateMachine() extension, as well as support for hierarchical state machines and SwiftUI integration for iOS.

Tokens
15K
Snippets
33
Records
45
Agent score
74%

What's inside FlowRedux

  1. Explore the Sample Android App

    main

    The sample/android directory contains a reference implementation of FlowRedux for Android. It provides two distinct ways to integrate FlowRedux into an Android application:

    1. Jetpack Compose: A sample Activity demonstrating how to use FlowRedux within a modern declarative UI framework.
    2. Traditional Android UI: A sample Activity demonstrating integration with the standard Android View system (e.g., using RecyclerView).
  2. How to act across multiple states using state hierarchies

    main

    In FlowRedux, you can define logic that applies to a group of states by using the inState<T> DSL function on a common base class or interface in your state hierarchy.

    • Global actions: Using inState<BaseInterface> allows you to define on, onEnter, or collectWhileInState blocks that remain active as long as the state machine is in any state that implements that interface. These blocks are never canceled during transitions between sub-states.
    • Subset actions: By introducing intermediate sealed interfaces (e.g., PostLoading), you can group specific states together. An inState<IntermediateInterface> block will be active whenever the current state is one of the implementations of that intermediate interface.
    // Example of a hierarchy allowing shared logic
    sealed interface ListState {
        object Loading : ListState
    
        sealed interface PostLoading : ListState
    
        data class ShowContent(val items: List<Item>) : PostLoading
        data class Error(val message: String) : PostLoading
    }
    
    // DSL usage
    spec {
        // Active during ShowContent OR Error
        inState<PostLoading> {
            // on, onEnter, collectWhileInState
        }
    
        // Active for ALL states in the machine
        inState<ListState> {
             // on, onEnter, collectWhileInState
        }
    
        // Active ONLY during Loading
        inState<Loading> {
            // on, onEnter, collectWhileInState
        }
    }
  3. Use inState to define state-specific logic

    main

    The inState<State> function is a DSL entry point used within a FlowReduxStateMachineFactory to define logic that should only execute when the state machine is in a specific state. You provide the state class as a type parameter to the inState function.

    Example usage within a factory spec block:

    class ItemListStateMachineFactory(
        private val httpClient: HttpClient
    ) : FlowReduxStateMachineFactory<ListState, Action> {
    
        init {
            spec {
                initializeWith { Loading }
    
                inState<Loading> {
                    // Logic for when the machine is in the Loading state
                }
            }
        }
    }
    class ItemListStateMachineFactory(
        private val httpClient: HttpClient
    ) : FlowReduxStateMachineFactory<ListState, Action> {
    
        init {
            spec {
                initializeWith { Loading }
    
                inState<Loading> {
                    ...
                }
            }
        }
    }
  4. Use untilIdentityChanged to restart blocks when a state property changes

    main

    The untilIdentityChanged function allows you to define a block of logic that remains active until a specific property (the 'identity') of the state changes. When the identity changes, the current block is canceled and restarted.

    This is particularly useful for 'master-detail' UI patterns where selecting a new item (like an email) should cancel any ongoing loading processes for the previous item and trigger a new loading process for the new item.

    Key behaviors:

    • It works in conjunction with the surrounding condition (e.g., it only runs while the state machine is in a specific state).
    • The block starts immediately upon entering the surrounding state.
    • It tracks the 'identity' provided by the lambda. If the value returned by that lambda changes, the block is canceled and restarted.
    spec {
        inState<InboxState> {
            // ... other handlers
    
            untilIdentityChanged({ state -> state.selectedEmail?.emailId }) {
                // This block will be canceled and restarted whenever emailId changes
                onEnter { state ->
                    val s = state.snapshot
                    if (s.selectedEmail != null) {
                        val details = loadEmailDetails(s.selectedEmail.emailId)
                        state.mutate {
                            copy(selectedEmail = selectedEmail.copy(details = details))
                        }
                    } else {
                        state.noChange()
                    }
                }
            }
        }
    }
  5. Jump to a specific state using initializeWith

    main

    To avoid testing every single state transition from the beginning, you can jump directly to a specific state by calling initializeWith { ... } on your FlowReduxStateMachineFactory before starting the state machine. This is useful for testing specific behaviors like retry logic or error countdowns from a known state.

    @Test
    fun `from Error state to Loading if RetryLoadingAction is dispatched`() = runTest {
        val initialState = Error(message = "A network error occurred", countdown = 3)
        val factory = ItemListStateMachineFactory(httpClient)
        factory.initializeWith { initialState }
        val stateMachine = factory.shareIn(backgroundScope)
    
        stateMachine.state.test {
            assertEquals(initialState, awaitItem())
            // now we dispatch the retry action
            stateMachine.dispatch(RetryLoadingAction)
    
            // next state should then be Loading
            assertEquals(Loading, awaitItem())
        }
    }
  6. Apply conditional logic with condition blocks

    main

    The condition({ predicate }) { ... } block allows you to define behavior that only executes if a specific condition regarding the current state is met.

    Rules and Constraints:

    • The condition is evaluated while the machine is in the specified inState.
    • You can nest untilIdentityChanged inside a condition block.
    • Constraint: You cannot nest condition blocks inside other condition blocks.
    inState<State1> {
      condition({ state.someString == "Hello" }) {
        on<Action3> { action -> ... }
        onEnter { ... }
    
        untilIdentityChanged({ state.id }) {
          on<Action3> { action -> ... }
        }
      }
    }
  7. How ChangeableState<T> and ChangedState<T> work

    main

    In FlowRedux, ChangeableState<T> is a receiver type used within DSL blocks (such as onEnter { ... }). It provides access to the current state and the mechanisms required to transition or update that state.

    Key Concepts

    • ChangeableState<T>: The object you interact with inside DSL blocks. It allows you to read the current state via .snapshot and trigger state changes.
    • ChangedState<T>: The result returned by mutation functions. It is an internal signal to the FlowReduxStateMachine on how to compute the next state. Do not instantiate ChangedState manually.
    • State Transitions vs. Mutations: FlowRedux distinguishes between moving to a completely different state type (e.g., from Loading to Error) and updating properties within the same state type (e.g., incrementing a counter in ScreenStatisticsState).
    spec {
        inState<Loading> {
            onEnter {
                // 'this' is ChangeableState<Loading>
                override { Error() }
            }
        }
    }
  8. How hierarchical state machines work in FlowRedux

    main

    FlowRedux supports hierarchical state machines, allowing you to compose complex business logic by nesting smaller, reusable state machines within a parent state machine. This approach favors composition over inheritance and keeps state machines decoupled and encapsulated.

    Key Concepts

    • Composition: You can trigger a child state machine from a parent state machine based on specific actions or state transitions.
    • Lifecycle: A child state machine started via onActionStartStateMachine() is kept alive as long as the parent remains in the specific inState<S> block that triggered it. If the parent transitions to a different state, the child state machine is automatically canceled.
    • Action Uniqueness: Child state machines are triggered by actions. If a new action is dispatched that is .equals() to a currently running child action, the existing child machine is canceled and a new one starts with the latest action.
    • State Integration: When a child state machine changes its state, you must provide a handler (or stateMapper) to define how that new child state should be merged into or reflected within the parent's state (e.g., using mutate or override).
  9. Use the ChangeableState and ChangedState pattern for logic extraction

    main

    When extracting logic from a FlowRedux spec block into standalone functions, use the ChangeableState<T> receiver and return a ChangedState<State> (or ChangedState<T>) to maintain compatibility with the DSL's state transition mechanism.

    This pattern allows you to use override { ... } within your functions to trigger state changes or transitions, effectively treating the extracted function as a modular piece of the state machine's logic.

    Standard Signature for Extracted Logic:

    suspend fun ChangeableState<T>.yourFunctionName(): ChangedState<State>
    // Example of a function following the recommended signature
    private suspend fun ChangeableState<Loading>.loadItemsAndMoveToContentOrError(): ChangedState<State> {
        return try {
            val items = httpClient.loadItems()
            override { ShowContent(items) }
        } catch (t: Throwable) {
            override { Error(cause = t, countdown = 3) }
        }
    }
  10. Choose the correct ExecutionPolicy for your use case

    main

    FlowRedux provides four execution policies to manage concurrent or rapid action triggers:

    1. CancelPrevious (Default): Automatically applied if no policy is specified. It cancels any currently running execution of the handler and starts the latest one. Use this when only the most recent action matters.

    2. Unordered: Allows multiple executions to run in parallel without canceling previous ones. There is no guarantee regarding the order in which these executions will complete. Use this when actions can run independently and order is not critical.

    3. Ordered: Prevents parallel execution and does not cancel previous executions. Instead, it ensures that executions are processed sequentially, preserving the order in which they were triggered.

    4. Throttled(duration: Duration): Limits execution to once per specified duration. If the action is triggered multiple times within the duration, only the first execution is performed.

  11. Use untilIdentityChanged to manage lifecycle based on state properties

    main

    The untilIdentityChanged({ property }) { ... } block allows you to group DSL elements (actions, flows, etc.) that should only be active as long as a specific property of the state remains constant.

    When the value returned by the lambda changes, the previous executions inside the block are canceled, and the block restarts with the new value.

    Supported inside the block:

    • on<Action>
    • onEnter
    • collectWhileInState
    • onActionEffect / onEnterEffect / collectWhileInStateEffect
    • onEnterStartStateMachine / onActionStartStateMachine

    Constraint: You cannot nest a condition block inside an untilIdentityChanged block.

    inState<State1> {
      untilIdentityChanged({ state.id }) {
        on<Action3> { action -> ... }
        onEnter { ... }
        collectWhileInState(flow) { value -> ... }
      }
    }
  12. Use custom conditions inside inState

    main

    While the recommended best practice in FlowRedux is to model state using sealed classes (where each state is its own type), you can use condition blocks inside inState<State> when you need more flexibility.

    A condition block allows you to define a state based on a predicate rather than just the type. The condition function accepts a lambda with the signature (State) -> Boolean. When this lambda returns true, the state is considered 'met', and the logic inside the block is executed.

    Inside a condition block, you can use the standard DSL features:

    • onEnter: Logic to execute when the condition becomes true.
    • on<Action>: Logic to execute when a specific action is received while the condition is met.
    • collectWhileInState: Logic to collect from a Flow as long as the condition remains true.
    inState<ListState> {
        condition({ state -> state.loading == true }) {
            onEnter {
                // Logic for when loading is true
            }
        }
    
        condition({ state -> state.error != null }) {
            on<RetryLoadingAction> {
                // Logic for retrying when an error exists
            }
        }
    }