One of the differences from what you will see in most of the tutorials about this approach is that we don't have a global application state. We simply don't need it as we build our app from a set of microfeatures, from a bootstrapping state when the user starts the app to the implementation of each and every application flow. Every feature has a clear "entry point" for the rest of the code to interact with it and has it's own state isolated from the rest of the app.
To demonstrate our approach we will build an interface that allows users to make a list of pharmacies where they prefer their medications to be delivered. This is a real feauture that I was working on recently. Here is how the final result will look like:
On the screen we are going to implement user will see an empty state placeholder when there are no previously saved pharmacies, will be able to add pharmacies from the map screen, delete previously added pharmacies and finally select a pharmacy for delivery, which will dismiss this screen.
As Redux in its essence is just a state machine we start with defining states and possible transitions between them. Apart from states and transitions, we will define "actions" - events that trigger state transitions, and "signals" - events sent out by the state machine to perform side effects i.e. presenting another screen.
We start with
loading state, of course. In case of loading data, in this case, the list of pharmacies previously saved by the user, succeeds we go to
loaded state, or otherwise in
loading failed state from where we can go back to
loading state using
loaded state using
add pharmacy action we go to
searching state. During this transition, we send
show map signal to display map user interface. When the user closes the map without selecting any pharmacy we go back to
loaded state. When the user selects a pharmacy on the map we go to
adding state. If adding pharmacy succeeds we go to
loaded state with this pharmacy added, otherwise we go to
failed state which immediately sends
show error signal and goes back to
Similarly to this when user deletes pharmacy we go to
deleting state and if deleting succeeds we go to
loaded state with this pharmacy deleted from the list, otherwise to
failed state, send
show error signal and go back to
loaded state keeping this pharmacy in the list.
Finally, when the user selects pharmacy from the list we can either exit the flow and return selected pharmacy to the caller through
dismissing state and
dismiss signal, or we can display directions to the selected pharmacy which will send
show map signal and then go to
showing directions state. When the user closes the map we go back to
Here is the final diagram. It looks a bit complicated but when you break it down into pieces like we just did it becomes pretty simple to understand.
Now let's implement it!
We start with bootstrapping feature using Xcode templates that we've created. This saves us from writing some boilerplate code and ensures that each implementation follows the same structure and naming conventions. Programming is a creative process but this creativity should be controlled in large teams.
Using the template we end up with few files each containing one type - builder, flow controller, view model, and renderer. Each of them serves its own single purpose. This break down is similar to what you might have seen in VIPER or other patterns. Let's go through these types one by one.
The builder is a main entry point to the feature. It defines and implements an external interface for the rest of the code to interact with the feature, i.e. here
func make() -> UIViewController. There might be several of such entry points depending on a use case. Implementation of this interface takes care of creating and wiring together all other components - flow controller, view model, and renderer. The output is typically a view controller. As you can see nothing except view controller leaves the builder. This way we achieve a high level of isolation of the feature making it a black box for the rest of the code - nothing but what we pass as input parameters to
make function (it's empty now but we will fill it soon) can affect the feature.
You are probably familiar with this concept already. You might have heard about "coordinators" and "routers". The flow controller is a similar concept. Its responsibility is to implement navigation. For navigation, we use a concept of "flows" throughout our app. It serves us as an abstraction of different presentation styles, mostly modal and navigation, used to present view controllers. Method
func handle(_: PharmaciesListViewModel.Route) implements the navigation logic and is an observer of the view model's
routes signal. In this method, the flow controller decides what and how to present depending on the
route value it receives from a view model.
As the name implies this type renders UI. It does it using Bento, our declarative UI framework. The way it works is similar to React. Instead of working with views directly we define a virtual representation of user interface as a tree-like structure. Similar to React this representation is a function of the state. When a new tree is created as a result of the state change the difference between the old and the new tree is calculated and the user interface is updated accordingly. As our app contains a lot of forms the main focus of Bento is table and collection views. In fact, even screens that don't look like forms, i.e. empty state screen that you could see on the video, are implemented using this approach. Almost every screen in our app is either table or collection view powered by Bento (or its predecessor).
This is a central piece of the puzzle that implements all the business logic and controls the state. You can imagine that it's a rather complex type so let's look at its pieces one by one. The main building blocks of every view model are
reducer function and
routes signal. The
state represents the state of our system,
reducer is a function that based on the current
state and incoming
event produces a new
state. And the
routes is the signal observed by the flow controller so that it can perform navigation.
Now, remember the state diagram we designed in the beginning? The "state" from this diagram is, obviously,
state property of a view model,
routes is a "signal", i.e. "show map" or "dismiss", and
reducer function is what implements transitions between states.
Let's look closer at the
state property. As you can see it's not a regular ReactiveSwift
Property. Apart from the initial value, it has a
Under the hood ReactiveFeedback framework will create a state machine like system which will use
reduce function to produce new states when new events occur, and
feedbacks to perform side-effects on state changes which can result in new events and further state changes. This way it defines a "feedback loop" - every time the state changes (including when it is created with its initial value) the system will go through all the feedbacks, some of them will trigger side effects, i.e. an asynchronous network call, as a result of this side effect an event can be emitted which then will be passed to the reducer along with the current state and the reducer will either produce a new state or return the current state (effectively discarding the event) and the loop will start over. As a result of every state change, the
state property current value will be updated. Now note that it's a
Property, not a
MutableProperty. This means that this property value can't be changed from outside. The only way to change it is through feedbacks and events. This way we make sure that nothing but the state machine itself can affect its state.
This may sound familiar to you if you like me had to study control theory as it uses the concept of feedbacks extensively. I was not very attentive student back in my university days so it took me some time to realize that similarity.
Now let's look at the feedback. We have only one for now and it's pretty simple. All that it does is transforms events caused by user interactions with UI into state machine events.
func send(action:) function is passed to the renderer as
observer parameter (it has a
Sink typealias which is actually just a typealias of
(Action) -> Void so it matches the signature of this function). The renderer then binds this observer with
UIControl.Events of controls that it renders.
Action type here represents a subset of state machine events that can be triggered by the user. For example, when the user taps a button it will produce the
The rest of the view model code created by the template contains definitions of
Let's start filling them in.
Here is how the states that we defined earlier can be represented.
All the states that are possible after the content is loaded contain the list of pharmacies. This is done so that we can pass it from one state to another when performing transitions. Sometimes we extract this kind of common associated values in a separate type
Context, but here we have only this piece of data to carry on, so we don't really need it, though we could define it just as
typealias Context = [PharmacyDTO].
You can see that we don't have a
failed case that we had on the state diagram. Instead, we combine it with
loaded state by adding optional
error as its second associated value. This way we model two states using one enum case - optionality of
error property allows using one case for two distinctive states, with and without error.
The last interesting detail here is the
dismissing state. It has an optional
PharmacyDTO which will contain a pharmacy user selected for delivery (the "output" of the feature) or
nil if the user dismisses the screen without selecting anything, and
state which is the state in which the screen was dismissed - it can be
loadingFailed, because in all these states we show a "Cancel" button that the user can press. Now, remember that we treat UI as a function of the state. Every time the state changes the renderer will be invoked to produce a new UI based on a new state. Then it will be invoked for the
dismissing state and will need to return something. But when the screen is dismissing we don't want anything on it to change, so we need to create exactly the same UI tree that we returned for the previous state. So we bundle it within
dismissing state to be able to render it.
Events and Actions
State transitions happen on events. Some events can happen due to side effects like network calls, and some can happen due to user interactions with UI elements. To make the distinction clearer we separate UI events into
Actions type and use a single
Event case for all of them,
case ui(Action). We also allow a different behavior of the feature - in some flows we want to show directions to the selected pharmacy and in others we just return selected pharmacy. For that, we use
Reducer is the central part of any view model. It is a function that defines all state transitions and considering a number of states and transitions we have you can guess it's pretty complex. To simplify it we will break it into pieces, handling events for each state in a separate function. This will leave us with pretty boilerplate implementation:
The main logic is now broken down into small pieces that are much easier to digest. When loading we need to handle loading failure, success and cancelation events. All other events we ignore by returning the current state:
When loading failed we only care about retry event and cancelation:
Loaded state reducer is a bit more complex as it has more possible transitions, but in the end, it's also pretty straight-forward. When a pharmacy is selected we also need to check an action assoicated with selection to either show the map or dismiss the screen.
deleting states reducers are pretty similar and straight-forward as well. When a pharmacy is added we add it to the pharmacies list and return to
loaded state. When a pharmacy is deleted we remove it from the pharmacies list and return to
loaded state. In case of an error, we return
loaded state with an error value.
When the user searches for a pharmacy on a map we only need to handle the event of selecting a place on the map. To avoid adding the same pharmacy multiple times we check if it is already on the list to decide if we should add it or ignore it.
Finally, when the map is presented to show directions to the pharmacy we are not actually interested in handling any specific events because the map, in this case, does not provide any return value, so we simply can return
.loaded state on
didLoad event that as you will see soon happens right after state changes to
showingDirections. Alternatively, we could produce an
Event when the map is dismissed and hande it here so that our system stays in the
showingDirections state while the map is presented.
Now lets finally look at the most interesting yet simple part of the view model - feedbacks. You already saw one feedback that effectively maps UI actions into state changes. The rest of the feedbacks are handling all other events unrelated to UI interactions, mostly network calls responses. Typically each feedback has an effect only in a particular state. It is not something that is enforced by the ReactiveFeedback so on one event multiple feedbacks can be invoked and can result in multiple side-effects being triggered.
Let's start with a loading feedback. All that it does is starting a network call to fetch a list of saved pharmacies and map its result into
Similarly feedbacks for adding and deleting pharmacy trigger network calls and map their results into
When showing directions or when an error happens (i.e. network request fails) we only need to present a map or an alert with an error message. This only involves navigation logic and if you remember for that we use the
routes signal. This way these feedbacks only need to restore the system to the
loaded state what they can do by producing
The last piece of the view model is
routes signal. It is observed by the flow controller to perform navigation when a new route is sent by the view model and effectively serves as an entry point into other features.
As you can see
routes signal is directly derived from
state property. We could also derive it from
actions signal. In this case, we wouldn't need some states and corresponding feedbacks like
showingDirections and instead will observe action
select(index, .showDirections). But this breaks the property of our system that the state is the single source of truth of everything happening in it. Also having these "transient" states helps to have a more complete picture of the system.
With this we can now implement the flow controller that all fits into one function:
To wrap up let's see a high-level implementation of the renderer. It is a very simple, and pure, function that based on state value returns UI tree to be rendered by Bento framework. In most of the states, we are rendering a list of pharmacies. In the
.loadingFailed states or when no pharmacies are saved we are rendering screen in an "empty" state, which can render a spinner or a placeholder with an icon and an action button to reload the list or add the first pharmacy.
I will not go into more implementation details as it deserves a separate post, but if you are curious you can look at the Bento example or final gist.
Now you should have a picture of architecture we use to develop features. You can draw some lines between it and the VIPER in how we break responsibilities between types. One of the benefits of such separation is that extracting navigation and rendering logic from a view model allows us to separate it from UIKit related side-effects which makes it easier to unit test. Separating rendering logic also allows us to test it easily (using FBSnapshotTest) without caring about mocking network calls performed by the view model.
Using ReactiveFeedback to model the state machine which is the main driver of the view model helps us to control its side-effects which is also easy to test either by testing
reducer function directly or by testing view model as a black box asserting how the state changes when events happen. It also bridges the Redux approach with the reactive programming that is in the heart of our development culture.
By continuously applying this pattern across the app, with the help of Xcode templates, we make it easier for us to understand parts of it we didn't work on before or start a development of a new feature, even though some implementation details may differ. This is crucial to ensure maintainability of the code base that is actively worked on by more than 10 people at the same time, no matter what pattern you choose for that.