🌾 Harvest: Apple's Combine.framework + State Machine, inspired by Elm.

Overview

NOTE: This repository has been discontinued in favor of Actomaton.

🌾 Harvest

Swift 5.1 Build Status

Apple's Combine.framework (from iOS 13) + State Machine, inspired by Elm.

This is a sister library of the following projects:

Requirement

Xcode 11 (Swift 5.1 / macOS 10.15, iOS 13, ...)

Example

To make a state transition diagram like above with additional effects, follow these steps:

1. Define States and Inputs

// 1. Define `State`s and `Input`s.
enum State {
    case loggedOut, loggingIn, loggedIn, loggingOut
}

enum Input {
    case login, loginOK, logout, logoutOK
    case forceLogout
}

2. Define EffectQueue

enum EffectQueue: EffectQueueProtocol {
    case `default`
    case request

    var flattenStrategy: FlattenStrategy {
        switch self {
        case .default: return .merge
        case .request: return .latest
        }
    }

    static var defaultEffectQueue: EffectQueue {
        .default
    }
}

EffectQueue allows additional side-effects (Effect, a wrapper of Publisher) to be scheduled with a specific FlattenStrategy, such as flatMap (.merge), flatMapLatest (.latest), etc. In above case, we want to automatically cancel previous network requests if occurred multiple times, so we also prepare case request queue with .latest strategy.

3. Create EffectMapping (Effect-wise reducer)

// NOTE: `EffectID` is useful for manual effect cancellation, but not used in this example.
typealias EffectID = Never

typealias Harvester = Harvest.Harvester<Input, State>
typealias EffectMapping = Harvester.EffectMapping<EffectQueue, EffectID>
typealias Effect = Harvester.Effect<Input, EffectQueue, EffectID>

// Additional effects while state-transitioning.
let loginOKPublisher = /* show UI, setup DB, request APIs, ..., and send `Input.loginOK` */
let logoutOKPublisher = /* show UI, clear cache, cancel APIs, ..., and send `Input.logoutOK` */
let forceLogoutOKPublisher = /* do something more special, ..., and send `Input.logoutOK` */

let canForceLogout: (State) -> Bool = [.loggingIn, .loggedIn].contains

let mappings: [EffectMapping] = [

  /*  Input   |   fromState => toState     |      Effect       */
  /* ----------------------------------------------------------*/
    .login    | .loggedOut  => .loggingIn  | Effect(loginOKPublisher, queue: .request),
    .loginOK  | .loggingIn  => .loggedIn   | .empty,
    .logout   | .loggedIn   => .loggingOut | Effect(logoutOKPublisher, queue: .request),
    .logoutOK | .loggingOut => .loggedOut  | .empty,

    .forceLogout | canForceLogout => .loggingOut | Effect(forceLogoutOKPublisher, queue: .request)
]

EffectMapping is Redux's Reducer or Elm's Update pure function that also returns Effect during the state-transition. Note that queue: .request is specified so that those effects will be handled in the same queue with .latest strategy. Instead of writing it as a plain function with pattern-matching, you can also write in a fancy markdown-table-like syntax as shown above.

4. Setup Harvester (state machine)

// Prepare input pipe for sending `Input` to `Harvester`.
let inputs = PassthroughSubject<Input, Never>()

var cancellables: [AnyCancellable] = []

// Setup state machine.
let harvester = Harvester(
    state: .loggedOut,
    input: inputs,
    mapping: .reduce(.first, mappings),  // combine mappings using `reduce` helper
    scheduler: DispatchQueue.main
)

// Observe state-transition replies (`.success` or `.failure`).
harvester.replies.sink { reply in
    print("received reply = \(reply)")
}.store(in: &cancellables)

// Observe current state changes.
harvester.state.sink { state in
    print("current state = \(state)")
}.store(in: &cancellables)

NOTE: func reduce is declared to combine multiple mappings into one.

5. And let's test!

let send = inputs.send

expect(harvester.state) == .loggedIn    // already logged in
send(Input.logout)
expect(harvester.state) == .loggingOut  // logging out...
// `logoutOKPublisher` will automatically send `Input.logoutOK` later
// and transit to `State.loggedOut`.

expect(harvester.state) == .loggedOut   // already logged out
send(Input.login)
expect(harvester.state) == .loggingIn   // logging in...
// `loginOKPublisher` will automatically send `Input.loginOK` later
// and transit to `State.loggedIn`.

// 👨🏽 < But wait, there's more!
// Let's send `Input.forceLogout` immediately after `State.loggingIn`.

send(Input.forceLogout)                       // 💥💣💥
expect(harvester.state) == .loggingOut  // logging out...
// `forceLogoutOKublisher` will automatically send `Input.logoutOK` later
// and transit to `State.loggedOut`.

Please notice how state-transitions, effect calls and cancellation are nicely performed. If your cancellation strategy is more complex than just using FlattenStrategy.latest, you can also use Effect.cancel to manually stop specific EffectID.

Note that any sizes of State and Input will work using Harvester, from single state (like above example) to covering whole app's states (like React.js + Redux architecture).

Using Feedback effect model as alternative

Instead of using EffectMapping with fine-grained EffectQueue model, Harvest also supports Feedback system as described in the following libraries:

See inamiy/ReactiveAutomaton#12 for more discussion.

Composable Architecture with SwiftUI

Pull Request #8 introduced HarvestStore and HarvestOptics frameworks for Composable Architecture, especially focused on SwiftUI.

  • HarvestStore: 2-way bindable Store optimized for SwiftUI
  • HarvestOptics: Input & state lifting helpers using FunOptics

See Harvest-SwiftUI-Gallery for the examples.

See also Babylonpartners/ios-playbook#171 for further related discussion.

  • TODO: Write documentation

References

  1. ReactiveAutomaton (using ReactiveSwift)
  2. RxAutomaton (using RxSwift)
  3. iOSDC 2016 (Tokyo, in Japanese) (2016/08/20)
  4. iOSConf SG (Singapore, in English) (2016/10/20-21)

License

MIT

Comments
  • [Bug] Replacing `Property` to `@Published` failed due to segfault 11

    [Bug] Replacing `Property` to `@Published` failed due to segfault 11

    (This is just bug demonstration, and won't be merged into master)

    As of Xcode 11 beta 5, replacing Property to @Published failed due to segfault 11 for some reason.

    bug 
    opened by inamiy 1
  • Add additional `|` operator for table-style mapping with `World` injection

    Add additional `|` operator for table-style mapping with `World` injection

    By #26 , table-style mapping becomes hard to inject World, so this PR adds extra func | to resolve the issue.

    Example

    Previously:

            /// Sends `.loginOK` after delay, simulating async work during `.loggingIn`.
            let loginOKEffect = Effect<S>(queue: .request) { world in
                Just(StateDiagram.Input.loginOK)
                    .delay(for: world.simulatedAsyncWorkDelay, scheduler: world.scheduler)
            }
    
            /// Sends `.logoutOK` after delay, simulating async work during `.loggingOut`.
            let logoutOKEffect = Effect<S>(queue: .request) { world in
                Just(StateDiagram.Input.logoutOK)
                    .delay(for: world.simulatedAsyncWorkDelay, scheduler: world.scheduler)
            }
    
            let canForceLogout: (State) -> Bool = [.loggingIn, .loggedIn].contains
    
            let mappings: [StateDiagram.EffectMapping<S>] = [
                .login    | .loggedOut  => .loggingIn  | loginOKEffect,
                .loginOK  | .loggingIn  => .loggedIn   | .empty,
                .logout   | .loggedIn   => .loggingOut | logoutOKEffect,
                .logoutOK | .loggingOut => .loggedOut  | .empty,
    
                .forceLogout | canForceLogout => .loggingOut | logoutOKEffect
            ]
    

    This PR will allow to define closure-style Effect as follows:

            /// Sends `.loginOK` after delay, simulating async work during `.loggingIn`.
            let loginOKEffect = { (world: World<S>) -> Effect in
                Just(StateDiagram.Input.loginOK)
                    .delay(for: world.simulatedAsyncWorkDelay, scheduler: world.scheduler)
                    .toEffect(queue: .request)
            }
    
            /// Sends `.logoutOK` after delay, simulating async work during `.loggingOut`.
            let logoutOKEffect = { (world: World<S>) -> Effect in
                Just(StateDiagram.Input.logoutOK)
                    .delay(for: world.simulatedAsyncWorkDelay, scheduler: world.scheduler)
                    .toEffect(queue: .request)
            }
    
    opened by inamiy 0
  • Use `World` as argument of `EffectMapping`

    Use `World` as argument of `EffectMapping`

    This is a breaking change to use World inside EffectMapping rather than Effect to follow ReaderT Design Pattern for more flexibility in dependency injection.

    Additionally, toEffect / toResultEffect helper methods are added for easier Effect creation.

    opened by inamiy 0
  • Control the World

    Control the World

    This PR adds World type-parameter to let Effect own (World) -> AnyPublisher<Input, Never> (rather than plain publisher) so that Real-World-Dependencies e.g. FileManager or Date() can be injected during the AnyPublisher instantiation.

    And using contramapWorld will help bridging different Worlds in different modules.

    Credit

    This idea was first brought by @foolonhill and @stephencelis in the following discussion: https://twitter.com/foolonhill/status/1189518919717081088

    (If you find any issues in this impl, please let me know!)

    enhancement 
    opened by inamiy 0
  • Make `Effect` & `EffectMapping` a monoid structure

    Make `Effect` & `EffectMapping` a monoid structure

    This PR adds Effect, Mapping, and EffectMapping conforming to Monoid structure (2 instances are combine-able with associativity and has identity) for better composition.

    Please note that EffectMapping will now have multiple ways of conforming to Monoid since its internal structure is Input -> State -> (State, Effect)? returning Optional as the architectural choice.

    Because of this, static func reduce will now introduce enum ReduceStrategy { case first, all, tryAll } for various reducing strategies.

    (Previously, this reducing strategy was assumed to have .first only (picking one of the mapping from array), but this prevents from sibling mappings composition, so having .all is essentially needed to behave just as same behavior as Input -> State -> (State, Effect) which returns Identity(Monad) .

    enhancement 
    opened by inamiy 0
  • Revert #13 where `scheduler` is applied to `inputs` that cause delayed UI rendering

    Revert #13 where `scheduler` is applied to `inputs` that cause delayed UI rendering

    Related: #12 #13

    This PR reverts #13 where scheduler is also applied to external inputs. Unfortunately, this caused delayed UI rendering that becomes annoying especially in SwiftUI which uses synchronous UI binding update.

    Since inputs.receive(on: scheduler) is not a requirement for avoiding synchronous feedback crash (though it improves thread safety), this PR will apply scheduler only for effectInputs.

    Downside of this change is that user needs to be aware of thread-safety issue as commented in documentation:

    NOTE: This (`scheduler`) should be on the same thread as `inputs`
    

    But still, having scheduler parameter is (probably) better than going back before #12 .

    opened by inamiy 0
  • Add `EffectMapping.makeInout` & rename `Effect.none` to `.empty`

    Add `EffectMapping.makeInout` & rename `Effect.none` to `.empty`

    This PR adds EffectMapping.makeInout as inout-based initializer for easier mapping implementation.

    But to achieve this, EffectMapping.makeInout argument's return type will have Optional<Effect> where current implementation has Effect.none with ExpressibleByNilLiteral which becomes ambiguous to Optional, so I renamed Effect.none to .empty, and removed ExpressibleByNilLiteral.

    opened by inamiy 0
  • Add `scheduler` as `Harvester.init` parameter

    Add `scheduler` as `Harvester.init` parameter

    This PR adds scheduler as Harvester.init parameter since "synchronous Input feedback loop" (reentrancy) will cause crash in Combine.framework.

    Previously, it was user's responsibility to add receive(on:) whenever needed for synchronous side-effects and next inputs, but I decided to implement this logic inside Harvester to mitigate the verbose user-side implementation.

    opened by inamiy 0
  • Add `Effect.mapQueue` and `.invmapID`

    Add `Effect.mapQueue` and `.invmapID`

    This PR provides a way of transforming Effect<Input, Queue, ID> into Effect<Input, Queue2, ID2> by adding func mapQueue and func invmapID .

    Alongside Effect.mapInput, these functions are useful for EffectMapping composition.

    opened by inamiy 0
  • Change `Mapping` & `EffectMapping` function types to struct

    Change `Mapping` & `EffectMapping` function types to struct

    This PR changes Mapping & EffectMapping function types to struct. One benefit is that we can add methods on top of these new types e.g. toEffectMapping() .

    opened by inamiy 0
  • Add HarvestStore & HarvestOptics

    Add HarvestStore & HarvestOptics

    This PR adds HarvestStore and HarvestOptics targets to enhance the composable architecturing, especially focused on SwiftUI.

    • HarvestStore: 2-way bindable Store optimized for SwiftUI
    • HarvestOptics: Input & state lifting helpers using FunOptics

    For Optics, please see https://github.com/Babylonpartners/ios-playbook/pull/171 for more detail.

    enhancement 
    opened by inamiy 0
  • README contains broken code

    README contains broken code

    Sample code in README causes compile error. I test with Harvest v0.3.0 (Xcode 13.1) using new blank mac app.

    // compile error: Generic type 'EffectMapping' specialized with too few type parameters (got 2, but expected 3)
    typealias EffectMapping = Harvester.EffectMapping<EffectQueue, EffectID>
    // 'Effect' is not a member type of generic class 'MyApp.Harvester' (aka 'Harvester<Input, State>')
    typealias Effect = Harvester.Effect<Input, EffectQueue, EffectID>
    

    Full source code:

    import Harvest
    import Combine
    
    // 1. Define `State`s and `Input`s.
    enum State {
        case loggedOut, loggingIn, loggedIn, loggingOut
    }
    
    enum Input {
        case login, loginOK, logout, logoutOK
        case forceLogout
    }
    
    enum EffectQueue: EffectQueueProtocol {
        case `default`
        case request
    
        var flattenStrategy: FlattenStrategy {
            switch self {
            case .default: return .merge
            case .request: return .latest
            }
        }
    
        static var defaultEffectQueue: EffectQueue {
            .default
        }
    }
    
    // NOTE: `EffectID` is useful for manual effect cancellation, but not used in this example.
    typealias EffectID = Never
    
    typealias Harvester = Harvest.Harvester<Input, State>
    typealias EffectMapping = Harvester.EffectMapping<EffectQueue, EffectID>
    typealias Effect = Harvester.Effect<Input, EffectQueue, EffectID>
    
    // Additional effects while state-transitioning.
    let loginOKPublisher = Just(()) /* show UI, setup DB, request APIs, ..., and send `Input.loginOK` */
    let logoutOKPublisher = Just(()) /* show UI, clear cache, cancel APIs, ..., and send `Input.logoutOK` */
    let forceLogoutOKPublisher = Just(()) /* do something more special, ..., and send `Input.logoutOK` */
    
    let canForceLogout: (State) -> Bool = [.loggingIn, .loggedIn].contains
    
    let mappings: [EffectMapping] = [
    
      /*  Input   |   fromState => toState     |      Effect       */
      /* ----------------------------------------------------------*/
        .login    | .loggedOut  => .loggingIn  | Effect(loginOKPublisher, queue: .request),
        .loginOK  | .loggingIn  => .loggedIn   | .empty,
        .logout   | .loggedIn   => .loggingOut | Effect(logoutOKPublisher, queue: .request),
        .logoutOK | .loggingOut => .loggedOut  | .empty,
    
        .forceLogout | canForceLogout => .loggingOut | Effect(forceLogoutOKPublisher, queue: .request)
    ]
    
    

    https://github.com/inamiy/Harvest/tree/0.3.0#3-create-effectmapping-effect-wise-reducer

    opened by mtgto 0
  • macOS via Catalyst + SwiftUI -- is this a supported use case?

    macOS via Catalyst + SwiftUI -- is this a supported use case?

    I'm looking to use this library across both iOS & macOS -- is that a supported use case?

    I ran into some problems testing the SwiftUI as a Catalyst app. -- Filed them here: https://github.com/inamiy/Harvest-SwiftUI-Gallery/issues/1

    Please feel free to close these tickets if you'd rather address these questions in a different way! Thanks!

    opened by fbartho 1
Owner
Yasuhiro Inami
Functional Programmer at @delyjp / KURASHIRU / クラシル. Interests: Swift / Haskell / PureScript / Elm / Rust / TypeScript / Category Theory
Yasuhiro Inami
Elm-parcel-capacitor - A sample setup to build an app with Elm, Capacitor, Parcel and Tailwind CSS

Elm, Capacitor, Parcel and Tailwindcss This project is a sample setup to build a

Anthonny Quérouil 10 May 9, 2022
Elegant state machine for Swift.

SwiftState Elegant state machine for Swift. Example enum MyState: StateType { case state0, state1, state2 } // setup state machine let machine = S

ReactKit 885 Dec 16, 2022
A lightweight Elm-like Store for SwiftUI

ObservableStore A simple Elm-like Store for SwiftUI, based on ObservableObject. ObservableStore helps you craft more reliable apps by centralizing all

Subconscious 28 Nov 8, 2022
Unidirectional State Management Architecture for Swift - Inspired by Vuex and Flux

Unidirectional State Management Architecture for Swift - Inspired by Vuex and Flux Introduction VueFlux is the architecture to manage state with unidi

Ryo Aoyama 324 Dec 17, 2022
RxReduce is a lightweight framework that ease the implementation of a state container pattern in a Reactive Programming compliant way.

About Architecture concerns RxReduce Installation The key principles How to use RxReduce Tools and dependencies Travis CI Frameworks Platform Licence

RxSwift Community 125 Jan 29, 2022
Open source implementation of Apple's Combine framework for processing values over time.

OpenCombine Open-source implementation of Apple's Combine framework for processing values over time. The main goal of this project is to provide a com

OpenCombine 2.4k Jan 2, 2023
This Repository holds learning data on Combine Framework

Combine Framework List of Topics Welcome, every section in this repo contains a collection of exercises demonstrating combine's utilization as well as

Julio Ismael Robles 2 Mar 17, 2022
Predictable state container for Swift too

ReduxSwift ReduxSwift is a minimal Swift port of Redux, a popular JavaScript library for application state management. Functionality Centralized State

Lucas Sunsi Abreu 38 Oct 6, 2020
🟣 Verge is a very tunable state-management engine on iOS App (UIKit / SwiftUI) and built-in ORM.

Verge.swift ?? An effective state management architecture for iOS - UIKit and also SwiftUI ?? _ An easier way to get unidirectional data flow _ _ Supp

VergeGroup 478 Dec 29, 2022
CMPSC475 Final Project, ArboretumID Application allows users to explore the Penn State Arboretum, identify plants and learn about the exhibits!

ArboretumID: CMPSC475 Final Project Taylan Unal (@taylanu) About ArboretumID ArboretumIdentifier (ArboretumID) is an app that enhances the Penn State

Taylan 1 Nov 27, 2021
Redux for Swift - a predictable state container for Swift apps

Merge / deprecation announcement: ReduxKit and Swift-Flow have joined forces! The result is ReSwift. The nitty gritty: We decided to deprecate ReduxKi

null 613 Jan 3, 2023
MVVM + FLUX iOS Instagram client in Swift, eliminates Massive View Controller in unidirectional event/state flow manner

CZInstagram MVVM + FLUX iOS Instagram client in Swift, eliminates Massive View Controller in unidirectional event/state flow manner. Unidirectional Da

Cheng Zhang 56 Nov 1, 2022
An experimental time traveling state store for SwiftUI

SwiftUI Time Travel A SwiftUI state store and view that allow you to scrub through an application's state. This is a super rough prototype: it's only

Tim Donnelly 139 Sep 14, 2022
A super simple library for state management with unidirectional data flow.

OneWay ?? OneWay is still experimental. As such, expect things to break and change in the coming months. OneWay is a super simple library for state ma

SeungYeop Yeom 41 Dec 20, 2022
Unidirectional Data Flow in Swift - Inspired by Redux

ReSwift Supported Swift Versions: Swift 4.2, 5.x For Swift 3.2 or 4.0 Support use Release 5.0.0 or earlier. For Swift 2.2 Support use Release 2.0.0 or

null 7.3k Dec 25, 2022
Support Combine Assign subscriber in RxSwift.

RxAssign Support Combine Assign subscriber in RxSwift. Assign uses a KeyPath which is really nice and useful. ** RxAssign extends Driver and Signal in

Won Heo 3 Dec 7, 2021
Unidirectional Data Flow in Swift - Inspired by Redux

ReSwift Supported Swift Versions: Swift 4.2, 5.x For Swift 3.2 or 4.0 Support use Release 5.0.0 or earlier. For Swift 2.2 Support use Release 2.0.0 or

null 7.3k Jan 3, 2023
Flux for SwiftUI, inspired by Vuex

⚠️ Fluxus is no longer maintained, and may not be using latest SwiftUI best practices. ?? I encourage you to look at the source of Fluxus. If you do,

John Susek 84 Jul 31, 2022
Cocoa framework and Obj-C dynamism bindings for ReactiveSwift.

Reactive extensions to Cocoa frameworks, built on top of ReactiveSwift. ⚠️ Looking for the Objective-C API? ?? Migrating from RAC 4.x? ?? Release Road

null 20k Jan 8, 2023