Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Reactive State Machines using Feedback Loops

Reactive State Machines using Feedback Loops

"Vanilla" Reactive (FRP) has some limitations when modelling stateful environments or when there are cyclic dependencies. In the same way, "Vanilla" State Machines (FSM) aren't straightforward to use in a reactive and concurrent setting. In this talk we explore using Feedback Loops to overcome some of these limitations by solving a very common problem we face in mobile apps: paginated search (lazy loading).

André Pacheco Neves

November 27, 2019
Tweet

Other Decks in Programming

Transcript

  1. HI! I am André Pacheco Neves iOS Developer @ Mindera

    andre.neves [at] mindera [dot] com p4checo @p4checo
  2. 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
  3. 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!)
  4. 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
  5. 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
  6. 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
  7. 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
  8. Let’s start with a single request... let fetch: SignalProducer<Result<[String], Error>,

    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?
  9. 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
  10. 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
  11. Introducing the Feedback Loops (a.k.a “the Workers”) static func newSearchFeedback(url:

    Signal<URL, Never>) -> Feedback<State, Event> { 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
  12. static func searchRequestFeedback(store: SearchStore) -> Feedback<State, Event> { return Feedback(

    lensing: { $0.nextPageURL }, effects: { nextPageURL -> SignalProducer<Event, Never> 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
  13. 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<Void, Never> ) -> Feedback<State, Event> { 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
  14. Tying it all together (the System) let (newSearch, newSearchObserver) =

    Signal<URL, Never>.pipe() let (nearBottom, nearBottomObserver) = Signal<Void, Never>.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
  15. 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<Int>) 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
  16. 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
  17. 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
  18. RxFeedback public typealias Feedback<State, Event> = (ObservableSchedulerContext<State>) -> Observable<Event> extension

    ObservableType where Element == Any { public static func system<State, Event>( initialState: State, reduce: @escaping (State, Event) -> State, scheduler: ImmediateSchedulerType, feedback: [Feedback<State, Event>] ) -> Observable<State> { ... } }
  19. ReactiveFeedback public struct Feedback<State, Event> { let events: (Scheduler, Signal<State,

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

    = QueueScheduler.main, reduce: @escaping (Value, Event) -> Value, feedbacks: [Feedback<Value, Event>] ) -> SignalProducer<Value, Never> { return SignalProducer.deferred { let (state, stateObserver) = Signal<Value, Never>.pipe() let events = feedbacks.map { feedback in return feedback.events(scheduler, state) } return SignalProducer<Event, Never>(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<Value, Error> ) -> SignalProducer<Value, Error> { return SignalProducer { $1 += producer().start($0) } } Feedback loop(s) Call reducer for each new event Pass in initial state
  21. ReactiveFeedback (anatomy) extension Property { public convenience init<Event>( initial: Value,

    scheduler: Scheduler = QueueScheduler.main, reduce: @escaping (Value, Event) -> Value, feedbacks: [Feedback<Value, Event>] ) { 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
  22. 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
  23. ReactiveFeedback (anatomy) public struct Feedback<State, Event> { let events: (Scheduler,

    Signal<State, Never>) -> Signal<Event, Never> public init<U, Effect: SignalProducerConvertible>( deriving transform: @escaping (Signal<State, Never>) -> Signal<U, Never>, 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
  24. ReactiveFeedback (anatomy) public struct Feedback<State, Event> { public init<Control, Effect:

    SignalProducerConvertible>( 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<Effect: SignalProducerConvertible>( 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
  25. 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 } }