Slide 1

Slide 1 text

Reactive State Machines using Feedback Loops André Pacheco Neves | CocoaHeads Porto | November 2019

Slide 2

Slide 2 text

HI! I am André Pacheco Neves iOS Developer @ Mindera andre.neves [at] mindera [dot] com p4checo @p4checo

Slide 3

Slide 3 text

What’s this talk about? Let’s break it down...

Slide 4

Slide 4 text

REACTIVE FEEDBACK LOOPS STATE MACHINES USING

Slide 5

Slide 5 text

Breaking it down... Reactive Short for Functional Reactive Programming (FRP). State Machine ⚙ Short for Finite-State Machine (FSM). Feedback Loop ♻ System that feeds back into itself. Combine ReactiveX

Slide 6

Slide 6 text

Functional Reactive Programming (in a nutshell) › Declarative programming paradigm. › Concerned with data streams and the propagation of change. › Uses the building blocks of functional programming (e.g. map, reduce, filter). We’ll be using on this talk (but all concepts should apply™ to any FRP framework and language!)

Slide 7

Slide 7 text

Finite State Machine (in a nutshell) › Mathematical model of computation › Can be in exactly one of a finite number of states › Can transition from one state to another in response to events and/or conditions › Frequently expressed by a reducer function

Slide 8

Slide 8 text

Feedback (Loop) Systems (in a nutshell) › Outputs of a system are routed back as inputs as a part of a chain of cause-and-effect that forms a circuit or loop

Slide 9

Slide 9 text

Why? What’s the actual problem we’re trying to solve?

Slide 10

Slide 10 text

What’s the problem? “Vanilla” FRP is great... › We model streams of data declaratively › We use functional primitives to manipulate these streams › We have bindings (e.g. for UI) and observables › ... … but › It doesn’t model cyclic dependencies well (e.g. lazy loading via paginated API) › It doesn’t model stateful environments easily (e.g. user authentication) “Classic” State Machines are great... › We model our possible states clearly › We model our possible events clearly › We model our transitions clearly › We define the business logic in each state › … … but › They aren’t straightforward to use in a reactive setting › Extra care must be taken to use in a concurrent environment › Cancelling ongoing side-effects can be tricky

Slide 11

Slide 11 text

Implementing paginated search: › Request first page, using URL A › Store the response (state*) somewhere › Scroll to the bottom › Request next page, using URL B (obtained from state*) › ... › Start a new search*? Cyclic data dependency * Stateful environment State machine? What’s the problem? (an example) * Side-effect cancellation

Slide 12

Slide 12 text

Let’s start with a single request... let fetch: SignalProducer, Never> = store.search(url: URL(string: "https://my.api.com/search?q=foo")!) .map { response in // 1. how to store the next page URL + current results? log.info(" Yay! we've got some new results!") return Result.success(response.results) } .flatMapError { error in log.error(" Oh noes! Something went wrong: \(error)") return .value(Result.failure(error)) } // 2. how to input the next page’s URL into the chain? // 3. How to handle user input while ensuring we’re in the correct state?

Slide 13

Slide 13 text

Let’s step back... We should be able to model this as a state machine, right?

Slide 14

Slide 14 text

Modelling the State Machine (State+Event) enum State { case loadingPage(Context) case loadedPage(Context) case error(Error, Context) } struct Context { var page: Int var results: [String] var nextPageURL: URL? } enum Event { case newSearch(URL) case loadNextPage case success(Response) case failure(Error) } struct Response { var results: [String] var nextPageURL: URL? } Loading Page Loaded Page Error newSearch success failure loadNextPage

Slide 15

Slide 15 text

Modelling the State Machine (Reducer) extension State { static func reduce(state: State, event: Event) -> State { switch event { case .newSearch(let url): return .loadingPage(Context.firstPage(with: url)) case .loadNextPage: return .loadingPage(state.context) case .success(let response): return .loadedPage(state.context.merging(response: response)) case .failure(let error): return .error(error, state.context) } } } enum State { case loadingPage(Context) case loadedPage(Context) case error(Error, Context) } enum Event { case newSearch(URL) case loadNextPage case success(Response) case failure(Error) } Loading Page Loaded Page Error newSearch success failure loadNextPage

Slide 16

Slide 16 text

How about Reactive? We gained some nice and pure abstractions, but lost some powers along the way...

Slide 17

Slide 17 text

SOLUTION? *suspense drums*

Slide 18

Slide 18 text

FEEDBACK LOOPS !

Slide 19

Slide 19 text

Introducing the Feedback Loops (a.k.a “the Workers”) static func newSearchFeedback(url: Signal) -> Feedback { return Feedback( events: { scheduler, _ in url .map { Event.newSearch($0) } .observe(on: scheduler) } ) } Loading Page Loaded Page Error newSearch success failure loadNextPage Will fire whenever url fires Ensure events are delivered on the feedback’s queue

Slide 20

Slide 20 text

static func searchRequestFeedback(store: SearchStore) -> Feedback { return Feedback( lensing: { $0.nextPageURL }, effects: { nextPageURL -> SignalProducer in store.search(url: nextPageURL) .map { Event.success($0) } .flatMapError { .value(Event.failure($0)) } } ) } Introducing the Feedback Loops (a.k.a “the Workers”) Loading Page Loaded Page Error newSearch success failure loadNextPage extension State var nextPageURL: URL? { switch self { case .loadingPage(let context): return context.nextPageURL default: return nil } } } Will only generate an effect whenever lens is non nil

Slide 21

Slide 21 text

extension State var loadNextPage: Void? { switch self { case .loadedPage(let context), .error(_, let context): return context.isInitial ? nil : () default: return nil } } } Introducing the Feedback Loops (a.k.a “the Workers”) static func loadNextPageFeedback( nearBottom: Signal ) -> Feedback { return Feedback( lensing: { $0.loadNextPage }, effects: { _ in nearBottom.take(first: 1).map { Event.loadNextPage } } ) } Loading Page Loaded Page Error newSearch success failure loadNextPage Will only generate an effect whenever lens is non nil Only allow a single event to be produced each time

Slide 22

Slide 22 text

Tying it all together (the System) let (newSearch, newSearchObserver) = Signal.pipe() let (nearBottom, nearBottomObserver) = Signal.pipe() let store = SearchStore() let state = Property( initial: State.loadedPage(Context.initial), scheduler: QueueScheduler.main, reduce: State.reduce, feedbacks: [ Feedbacks.newSearchFeedback(url: newSearch), Feedbacks.searchRequestFeedback(store: store), Feedbacks.loadNextPageFeedback(nearBottom: nearBottom) ] ) Loading Page Loaded Page Error newSearch success failure loadNextPage

Slide 23

Slide 23 text

Using the state with optics // using it with an extra layer of abstraction let refreshResult = state.producer .filterMap { $0.resultRefresh } extension State { enum ResultRefresh { case loading case empty case firstPage(results: [String]) case nextPage(results: [String], newResultsRange: Range) case loadingNextPage(results: [String]) case error(results: [String], error: Error) } } Loading Page Loaded Page Error newSearch success failure loadNextPage We only want to update the UI on some particular states/conditions Only lets non nil values pass To efficiently update the UI

Slide 24

Slide 24 text

Using the state with optics (the prism) extension State { var resultRefresh: ResultRefresh? { switch self { case .loadingPage: return .loading case .loadedPage(let context) where context.isEmpty: return .empty case .loadedPage(let context) where context.page == 1: return .firstPage(results: context.results) case .loadedPage(let context): return .nextPage( results: context.results, newResultsRange: context.lastPageRange ) case .loadingPage(let context) where context.nextPageURL != nil: return .loadingNextPage(results: context.results) case .error(let error, let context): return .error(results: context.results, error: error) default: return nil } } } Loading Page Loaded Page Error newSearch success failure loadNextPage Discard states/conditions that don’t require UI update

Slide 25

Slide 25 text

What sorcery is this? Using reactive feedback loops we apparently made a reactive state machine...

Slide 26

Slide 26 text

What is Reactive Feedback? › Introduced in 2017(AFAIK) as: › https://github.com/NoTests/RxFeedback.swift › https://academy.realm.io/posts/try-swift-nyc-2017-krunoslav-za her-modern-rxswift-architectures/ › Ported in 2017 to ReactiveSwift as: › https://github.com/babylonhealth/ReactiveFeedback › Ported in 2019 to Combine as: › https://github.com/sergdort/CombineFeedback We’re using this one

Slide 27

Slide 27 text

RxFeedback public typealias Feedback = (ObservableSchedulerContext) -> Observable extension ObservableType where Element == Any { public static func system( initialState: State, reduce: @escaping (State, Event) -> State, scheduler: ImmediateSchedulerType, feedback: [Feedback] ) -> Observable { ... } }

Slide 28

Slide 28 text

ReactiveFeedback public struct Feedback { let events: (Scheduler, Signal) -> Signal } extension SignalProducer where Error == Never { public static func system( initial: Value, scheduler: Scheduler = QueueScheduler.main, reduce: @escaping (Value, Event) -> Value, feedbacks: [Feedback] ) -> SignalProducer { … } }

Slide 29

Slide 29 text

ReactiveFeedback (anatomy) public static func system( initial: Value, scheduler: Scheduler = QueueScheduler.main, reduce: @escaping (Value, Event) -> Value, feedbacks: [Feedback] ) -> SignalProducer { return SignalProducer.deferred { let (state, stateObserver) = Signal.pipe() let events = feedbacks.map { feedback in return feedback.events(scheduler, state) } return SignalProducer(Signal.merge(events)) .scan(initial, reduce) .on( started: { stateObserver.send(value: initial) }, value: stateObserver.send(value:) ) .prefix(value: initial) } } private static func deferred( _ producer: @escaping () -> SignalProducer ) -> SignalProducer { return SignalProducer { $1 += producer().start($0) } } Feedback loop(s) Call reducer for each new event Pass in initial state

Slide 30

Slide 30 text

ReactiveFeedback (anatomy) extension Property { public convenience init( initial: Value, scheduler: Scheduler = QueueScheduler.main, reduce: @escaping (Value, Event) -> Value, feedbacks: [Feedback] ) { let state = MutableProperty(initial) state <~ SignalProducer .system( initial: initial, scheduler: scheduler, reduce: reduce, feedbacks: feedbacks ) .skip(first: 1) self.init(capturing: state) } } Ignore first value as it’s already on the property * * Bind system to the property

Slide 31

Slide 31 text

Where can I use this? It’s probably more versatile than you think...

Slide 32

Slide 32 text

Possible uses › Sub systems (e.g. lazy loading) › Services (e.g. authentication) › ViewModels › Entire applications? › Anywhere a state machine is well suited › ... And it’s not even limited to iOS alone! e.g.: https://github.com/NoTests/RxFeedback.kt

Slide 33

Slide 33 text

THANKS! Any questions? You can find me at: andre.neves [at] mindera [dot] com p4checo @p4checo

Slide 34

Slide 34 text

› https://github.com/NoTests/RxFeedback.swift › https://academy.realm.io/posts/try-swift-nyc-2017-kru noslav-zaher-modern-rxswift-architectures/ › https://github.com/babylonhealth/ReactiveFeedback › https://github.com/ReactiveCocoa/ReactiveSwift › Presentation template by SlidesCarnival Credits

Slide 35

Slide 35 text

Backup Slides Better safe than sorry

Slide 36

Slide 36 text

ReactiveFeedback (anatomy) public struct Feedback { let events: (Scheduler, Signal) -> Signal public init( deriving transform: @escaping (Signal) -> Signal, effects: @escaping (U) -> Effect ) where Effect.Value == Event, Effect.Error == Never { self.events = { scheduler, state in // NOTE: `observe(on:)` should be applied on the inner producers, so // that cancellation due to state changes would be able to // cancel outstanding events that have already been scheduled. return transform(state) .flatMap(.latest) { effects($0).producer.observe(on: scheduler) } } } } Replace any ongoing effect Ensure events are delivered on system’s scheduler

Slide 37

Slide 37 text

ReactiveFeedback (anatomy) public struct Feedback { public init( lensing transform: @escaping (State) -> Control?, effects: @escaping (Control) -> Effect ) where Effect.Value == Event, Effect.Error == Never { self.init(deriving: { $0.map(transform) }, effects: { $0.map(effects)?.producer ?? .empty }) } public init( predicate: @escaping (State) -> Bool, effects: @escaping (State) -> Effect ) where Effect.Value == Event, Effect.Error == Never { self.init(deriving: { $0.filter(predicate) }, effects: effects) } } Will cancel any ongoing effects if lens returns nil Will only cancel/replace ongoing effects when predicate returns true

Slide 38

Slide 38 text

Context struct Context { var page: Int var results: [String] var nextPageURL: URL? func merging(response: Response) -> Context { return Context( page: page + 1, results: results + response.results, nextPageURL: response.nextPageURL ) } static func firstPage(with url: URL) -> Context { return Context(page: 0, results: [], nextPageURL: url) } static var initial: Context { return Context(page: 0, results: [], nextPageURL: nil) } var isInitial: Bool { return page == 0 && results.isEmpty && nextPageURL == nil } var isEmpty: Bool { return page == 1 && results.isEmpty && nextPageURL == nil } }