Moving to Model-View-Intent (MVI) using ReactorKit on iOS
I tried my hand at MVI after watching this talk by Jake Wharton. MVI is a nice upgrade to a lot of my existing MVVM code.
Below, you can see a typical view model I would write in MVVM for a screen that fetches some user details:
In the view controller, we can just drive the nameLabel and emailLabel using nameDriver and emailDriver.
This view model can be tested easily. It takes the business logic away from the view controller. It is not hard to debug.
But, most view models are not as simple as the one above. They contain many network requests and are used to back complex views.
As the number of network requests or the complexity of views grow, the number of streams to keep track of in the view model increases. It becomes cumbersome to reason about the logic of each stream individually.
As a solution, everything can be passed through a single observable stream where:
- the view controller captures all user inputs as intents and sends them to model through this stream (source).
- the model performs all the corresponding logic on the same stream and sends it back to the view (model).
- the view updates its UI using the stream (sink).
In my first attempt, the view model took a stream of Intents.
When an Intent entered the view model, it was mapped to an Action. Then, corresponding business logic (like network calls, computations, etc.) was performed to return a Result. Finally, the Result was fed into a scan that generated and returned a new State.
Intent, Action, Result and State were all enumerations.
This worked well for screens with simple UIs.
For screens with complex UIs, using an enumeration to represent the state was not a good idea. Within much time, I was writing a lot of imperative code in the render function to update the UI.
In my second attempt, I used a struct to represent the state instead of an enumeration. This improved render a lot:
The view updates were reactive.
In addition to converting the state to a struct, I added a shareReplay because there are multiple subscriptions to the state in the view.
While working with V2, I needed access to the previous state during compose.
Take a counter, for example. I need to have access to the existing count (which is stored in the state struct) before I can increment it and return the new count.
As a solution, I could store the previous state in the view model and access it in the compose.
In my third attempt, I used ReactorKit.
In ReactorKit, all the MVI-related model logic is abstracted away into a protocol called Reactor.
It also stores useful properties like the initialState and the currentState.
Below you can see how I would implement a simple profile screen using ReactorKit.
In the view model,
- define the actions, mutations (results are now called mutations) and state
- implement mutate to perform the logic for each action
- implement reduce to update the state based on the mutation
In the view controller,
- bind user input to reactor actions
- subscribe to the reactor state stream to update UI
This implementation of MVI is nicely decoupled, making it easy to test. It has one stream, making it easy to debug and reason about. This also makes it less prone to silly mistakes. It is especially worth it for complex views because of the debugging time it saves.
On the downside, it has increased my setup time by forcing me to think about actions, mutations and state before writing any code. But, doing this saved me time later more times than not.