Contents

CombineCommunity/Feedbacks

Feedbacks is a tool to build feedback loops within a Swift based application. Feedbacks relies on Combine and is compatible with SwiftUI and UIKit

Scheduling

Threading is very important to make a nice responsive application. A Scheduler is the Combine way of handling threading by switching portions of reactive streams on dispatch queues, operation queues or RunLoops.

The declarative syntax of Feedbacks allows to alter the behavior of a System by simply applying modifiers (like you would do with SwiftUI to change the frame for instance). Modifying the scheduling of a side effect is as simple as calling the .execute(on:) modifier.

Feedbacks {
  Feedback(on: LoadingState.self, strategy: .continueOnNewState) { state -> AnyPublisher<Event, Never> in
    performLongRunningOperation()
      .map { FinishedLoadingEvent() }
      .eraseToAnyPublisher()
    }
    .execute(on: DispatchQueue(label: "A background queue"))
}

As in SwiftUI, modifiers can be applied to the container:

Feedbacks {
  Feedback(on: LoadingState.self, strategy: .continueOnNewState) { state -> AnyPublisher<Event, Never> in
    ...
  }
    
  Feedback(on: SelectedState.self, strategy: .continueOnNewState) { state -> AnyPublisher<Event, Never> in
    ...
  }
}
.execute(on: DispatchQueue(label: "A background queue"))

Both side effects will be executed on the background queue.

It is also applicable to the transitions:

Transitions {
  From(VolumeState.self) { state in
    On(IncreaseEvent.self, transitionTo: VolumeState(value: state.value + 1))
    On(DecreaseEvent.self, transitionTo: VolumeState(value: state.value - 1))
  }
}.execute(on: DispatchQueue(label: "A background queue"))

or to the whole system:

```swift System { InitialState { VolumeState(value: 10) }

Feedbacks { Feedback(on: VolumeState.self, strategy: .continueOnNewState) { state -> AnyPublisher<Event, Never> in if state.value >= targetedVolume { return Empty().eraseToAnyPublisher() }

return Just(IncreaseEvent()).eraseToAnyPublisher() }

Feedback(on: VolumeState.self, strategy: .continueOnNewState) { state -> AnyPublisher<Event, Never> in if state.value <= targetedVolume { return Empty().eraseToAnyPublisher() }

return Just(DecreaseEvent()).eraseToAnyPublisher() } }

Transitions { From(VolumeState.self) { state in On(IncreaseEvent.self, transitionTo: VolumeState(value: state.value + 1)) On(DecreaseEvent.self, transitionTo: VolumeState(value: state.value - 1)) } } }.execute(on: DispatchQueue(label: "A background queue")) ```

Lifecycle

There are typical cases where a side effect consist of an asynchronous operation (like a network call). What happens if the very same side effect is called repeatedly, not waiting for the previous ones to end? Are the operations stacked? Are they cancelled when a new one is performed?

Well, it depends 😁. Every feedback constructor that takes a State as a parameter can also be passed an ExecutionStrategy:

  • .cancelOnNewState, to cancel the previous operation when a new state is to be handled
  • .continueOnNewState, to let the previous operation naturally end when a new state is to be handled (events will then be concatenated).

Dependencies

It is unlikely that a side effect don't need dependencies to perform its job. By design, a side effect is a function that can take only a state as an input. Fortunately, Feedbacks provide factory functions to help with the injection of dependencies in your side effects.

enum MySideEffects {
  static func load(
      networkService: NetworkService,
      databaseService: DataBaseService,
      state: LoadingState
  ) -> AnyPublisher<Event, Never> {
    networkService
      .fetch()
      .map { databaseService.save($0) }
      .map { LoadedEvent(result: $0) }
      .eraseToAnyPublisher()
  }
}

let myNetworkService = MyNetworkService()
let myDatabaseService = MyDatabaseService()
let loadingEffect = SideEffect.make(MySideEffects.load, arg1: myNetworkService, arg2: myDatabaseService)
let feedback = Feedback(on: LoadingState.self, strategy: .cancelOnNewState, perform: loadingEffect)

SideEffect.make() factories will transform functions with several parameters (up to 6 including the state) into functions with 1 parameter (the state), on the condition of the state being the last one.

Let's gain some altitude

A System relies on three things:

  • an initial state
  • some side effects
  • a state machine

Once these things are connected together, it forms a stream of States which we can subscribe to in order to run the System:

system.stream.sink { _ in }.store(&subscriptions)

or

system.run() // the subscription will live as long as the system is kept in memory

A System forms a loop that is also referred to as a feedback loop, where the state is continuously adjusted until it reaches a stable value:

<div style="text-align:center"> <img src="./Resources/system.png" height="300" style="border-radius: 20px;"/> </div>

Advanced usage

The modifiers

Here is a list of the supported modifiers:

| Modifier | Action | Can be applied to | | -------------- | -------------- | -------------- | | .disable(disabled:)| The target won't be executed as long as the disabled condition is true | <ul align="left"><li>Transition</li><li>Transitions</li><li>Feedback</li></ul> | | .execute(on:)| The target will be executed on the scheduler | <ul align="left"><li>Transitions</li><li>Feedback</li><li>Feedbacks</li><li>System</li></ul> | | .onStateReceived(perform:)| Execute the perform closure each time a new state is given as an input | <ul align="left"><li>Feedback</li><li>Feedbacks</li></ul> | | .onEventEmitted(perform:)| Execute the perform closure each time a new event is emitted | <ul align="left"><li>Feedback</li><li>Feedbacks</li></ul> | | .attach(to:)| Refer to the "How to make systems communicate" section | <ul align="left"><li>System</li><li>UISystem</li></ul> | | .uiSystem(viewStateFactory:)| Refer to the "Using Feedbacks with SwiftUI and UIKit" section | <ul align="left"><li>System</li></ul> |

As each modifier returns an updated instance of the target, we can chain them.

Feedback(...)
  .execute(on: ...)
  .onStateReceived {
    ...
  }
  .onEventEmitted {
    ...
  }

State and Event wildcards

Although it is recommended to describe all the possible transitions in a state machine, it is still possible to take some shortcuts with wildcards.

Transitions {
  From(ErrorState.self) {
    On(AnyEvent.self, transitionTo: LoadingState())
  }
}

Considering the state is ErrorState, this transition will produce a LoadingState whatever event is received.

Transitions {
  From(AnyState.self) {
    On(RefreshEvent.self, transitionTo: LoadingState())
  }
}

Everytime the RefreshEvent is received, this transition will produce a LoadingState whatever the previous state.

The different ways of instantiating a Feedback

A Feedback is built from a side effect. A side effect is a function that takes a state as a parameter. There are two ways to build a Feedback:

Feedback(on: AnyState.self, strategy: .continueOnNewState) { state in
  ...
  .map { _ in MyEvent() }
  .eraseToAnyPublisher()
}

This feedback will execute the side effect whatever the type of state that is produced. It could be useful if you want to perform a side effect each time a new state is generated, regardless of the type of State.

Feedback(on: LoadingState.self, strategy: .continueOnNewState) { state in
  ...
  .map { _ in MyEvent() }
  .eraseToAnyPublisher()
}

This Feedback will execute the side effect only when it is of type LoadingState.

Composing Transitions

The more complex a System, the more we need to add transitions. It's a good practice to split them into logical units:

let transitions = Transitions {
  From(LoadingState.self) { state in
    On(DataIsLoaded.self, transitionTo: LoadedState.self) { event in
      LoadedState(page: state.page, data: event.data)
    }
    On(LoadingHasFailed.self, transitionTo: ErrorState())
  }
    
  From(LoadedState.self) { state in
    On(RefreshEvent.self, transitionTo: LoadingState.self) {
      LoadingState(page: state.page)
    }
  }
}

or even externalize them into properties:

let loadingTransitions = From(LoadingState.self) { state in
  On(DataIsLoaded.self, transitionTo: LoadedState.self) { event in
    LoadedState(page: state.page, data: event.data)
  }
  On(LoadingHasFailed.self, transitionTo: ErrorState())
}
    
let loadedTransitions = From(LoadedState.self) { state in
  On(RefreshEvent.self, transitionTo: LoadingState.self) {
    LoadingState(page: state.page)
  }
}

let transitions = Transitions {
  loadingTransitions
  loadedTransitions
}

Unit testing you state machine

In order to ease the testing of your transitions you can import the "FeedbacksTest" library. It provides helper functions on the "Transitions" type.

Once you have a system, you can retrieve its transitions: let transitions = mySystem.transitions:

  • transitions.assertThat(from: VolumeState(value: 10), on: IncreaseEvent(), newStateIs: VolumeState(value: 11))
  • transitions.assertThatStateIsUnchanged(from: Loading(), on: Refresh())

How to make Systems communicate?

Systems should be self contained and limited to their business. We should pay attention to make them small and composable. It might occur that a feature is composed of several Systems. In that case we could want them to communicate together.

There is a pattern for that in OOP: Mediator. A Mediator acts as a communication bus between independent components in order to garantee their decoupling.

Feedbacks come with two types of Mediators: CurrentValueMediator and PassthroughMediator. They are basically typealises of CurrentValueSubject and PassthroughSubject.

To attach two Systems together:

let mediator = PassthroughMediator<Int>()

let systemA = System {
  ...
}.attach(to: mediator, onSystemState: LoadedState.self, emitMediatorValue: { _ in 1701 })

let systemB = System {
  ...
}.attach(to: mediator, onMediatorValue: 1701 , emitSystemEvent: { _ in LoadedDoneEvent() }))

When systemA emits a LoadedState state, the mediator will propagate the 1701 value among its subscribers and systemB will trigger a LoadedDoneEvent.

This way of doing is nice when you do not have a reference on the 2 systems at the same time. You can pass the mediator around or make sure a common instance is injected to you to make the link between your Systems.

If by chance you have a reference on both Systems, you can attach them without a mediator:

let systemA = System {
  ...
}

let systemB = System {
  ...
}

systemA.attach(
    to: systemB,
    onSystemStateType: LoadedState.self,
    emitAttachedSystemEvent: { stateFromA in
      LoadedEvent(data: stateFromA.data)
    }
)

When systemA encounters the state LoadedState, systemB will trigger a LoadedEvent event.

Using Feedbacks with SwiftUI and UIKit

Although a System can exist by itself without a view, it makes sense in our developer world to treat it as a way to produce a State that will be rendered on screen and expect events emitted by a user.

Fortunately, taking a State as an input for rendering and returning a stream of events from user interactions looks A LOT like the definition of a side effect; and we know how to handle them 😁 -- with a System of course. Feedbacks provides a UISystem class which is a decoration of a traditionnal System, but dedicated to UI interactions.

Depending on the complexity of your use case, you can use UISystem in two ways:

  • for simple cases, you can instantiate a UISystem from a System: The resulting system will publish a RawState, which is a basic encapsulation of your states. You will have to write functions in your Views to extract the information you need from them. You can find an example of implementation in the CounterApp demo application.
  • for more complex cases, you can instantiate a UISystem from a System and a viewStateFactory function: The resulting system will publish a ViewState which is the output from the viewStateFactory function. It allows to implement more complex mappings. You can find an example of implementation in the GiphyApp demo application.

UISystem has some specifics:

  • it ensures the states are published on the main thread
  • as it is an ObservableObject, it publishes a state property we can listen to in SwiftUI views or UIKit ViewControllers
  • it offers an emit(event:) function to propagate user events in the System
  • it offers some helper functions to build SwiftUI Bindings

enum FeatureViewState: State {
  case .displayLoading
  case .displayData(data: Data)
}

let stateToViewState: (State) -> FeatureViewState = { state in
  switch (state) {
  case is LoadingState: return .displayLoading
  case let loadedState as LoadedState: return .displayData(loadedState.data)
  ...
  }
}

let system = UISystem(viewStateFactory: stateToViewState) {
  InitialState {
    LoadingState()
  }
    
  Feedbacks {
    ...
  }
    
  Transitions {
    ...
  }
}

Alternatively, we can build a UISystem from a traditionnal System:

let system = System {
  InitialState {
    LoadingState()
  }
    
  Feedbacks {
    ...
  }
    
  Transitions {
    ...
  }
}

let uiSystem = system.uiSystem(viewStateFactory: stateToViewState)

Once started, we can inject the uiSystem into a SwiftUI View:

struct FeatureView: View {

  @ObservedObject var system: UISystem<FeatureViewState>

  var body: some View {
    switch (self.system.state) {
    case .displayLoading: ...
    case let .displayData(data): ...
    }
  }
	
  var button: some View {
    Button {
      Text("Click")
    } label: {
      self.system.emit(RefreshEvent())
    }
  }
}

or into a ViewController:

class FeatureViewController: ViewController {
  var subscriptions = [AnyCancellable]()
	
  func viewDidLoad() {
    self
      .system
      .$state
      .sink { [weak self] state in self?.render(state) }
      .store(in: &self.subscriptions)
  }
	
  func onClick() {
    self.system.emit(RefreshEvent())
  }
}

Examples

You will find a demo application in the Examples folder of the project. We will add new examples as the time goes by.

Package Metadata

Repository: CombineCommunity/Feedbacks

Stars: 51

Forks: 1

Open issues: 0

Default branch: main

Primary language: swift

License: MIT

Topics: dsl, feedback-loop, feedbacks, state-machine, swift

README: README.md