8

Reclaim the reactivity of your state management, say no to imperative MVI

 2 years ago
source link: https://zhuinden.medium.com/reclaim-the-reactivity-of-your-state-management-say-no-to-imperative-mvi-3b23ca6b8537
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Reclaim the reactivity of your state management, say no to imperative MVI

Do you find yourself chasing for “clean code, clean architecture, clean design, clean state management” yet still feel bogged down in a sea of boilerplate for even the simplest of tasks — such as showing a simple list fetched with a coroutine?

Surely this could be done in a single line or maybe about seven, but it certainly shouldn’t need gigantic case-whens, 3 layers of indirection, and so on?

Well, normally you could just invoke functions on ViewModel and it would work, but if you’re forced to seek the “architectural holy grail”, no one around you will trust your code unless you add at least 1 sealed class called ViewActions, and increase the cyclomatic complexity of your “action handler” function until it feels “just clean enough”.

(After all, surely the more completely unrelated things a single function does based on its argument, the more it has a “single responsibility” of handling literally everything, which is why you know it’s definitely the best possible way to do it. 😏)

Anyway, the boilerplate of coupling together all aspects into a single class, whether it is a function call or state property, this all has a history: namely, it came from the web.

The brief history of MVI

MVI stands for “model-view-intent” and comes from a (not very popular for use in production) Javascript framework called Cycle.js, hand-in-hand with a (not popular anymore) concept called “The Elm Architecture” defined as the best practices and intended use of an experimental (and since 2019, unmaintained) “functional-reactive programming language for the web” called ELM.

Then again, these didn’t come from a vaccuum either — the originator is Redux, in 2015. The general idea was to implement a state machine using the command processor pattern in Javascript, thereby supporting “undo” functionality (also often referred to as “time-travel debugging”).

Of course, most design decisions of Redux only make sense for Javascript — as it is a language with no static typing. It makes sense to have a single event handler such as .onEvent('click', function() {}) because there is no API discoverability in a dynamically typed language. You’re just trying to invoke strings as functions (or access strings as properties/values) and hope for the best.

The history of MVI on Android

MVI on Android comes from two places, first is the PRNSAASPFRUICC pattern from May 2016, which would model all UI events as intentions that were then merged into a single observable stream, although despite the talk, it never really got popular.

The next step was Mosby-MVI, although it brought in the concept of a single stored model that combines all asynchronously loaded state, user-input, and transient state such as loading — resulting in a “recommendation” that would lead to faulty design for Android apps:

From Reactive Apps with Model-View-Intent — Part 1: Model

“[…] we only need a Model class that is representing the whole state. Then it’s easy to save this Model into a bundle and to restore it afterwards. However, I [the author of that article] personally think that most of the time it is better to not save the state but rather reload the whole screen just like we are doing on first app start. […] When our app is killed and we save the state and 6 hours later the user reopens our app […]”

Therefore, we can clearly see that MVI for Android was designed with the following assumptions in mind:

  • that process death only happens 6+ hours later (even though you can experience process death just by opening your email app to get a registration code for email verification) therefore you “don’t need to support it at all”
  • that if you do support process death, then the 1 MB size limit isn’t an issue (hence why it would bundle all async-loaded data into the Bundle as part of the restored state, even in newer MVI frameworks such as Orbit)

“State reducers” as the culprit behind the imperative nature of state evaluation in MVI

On state reducers:

Reactive Apps with Model-View-Intent — Part 3: State Reducer

“State Reducer is a concept from functional programming that takes the previous state as input and computes a new state from the previous state. […] A state reducer fits perfectly into the philosophy of Model-View-Intent with an unidirectional data flow and a Model representing the State.”

MVI brings in the assumption that the best way to model any screen is to use a finite-state-machine (FSM), where each next state is evaluated based on the previously evaluated state.

_state.value = state.value.copy( // imperative MVI
userName = newUsername
)

And it all sounds “great” on paper, until you realize that this design brings limitations — namely, that to evaluate any state at any time, you always need to evaluate the previous one before you can evaluate the next.

State reducer

Imagine that your UI has an auto-complete text view in it, and the user can input any text. So when the user changes the incoming text, we need to launch a new DB query to reflect the latest filter parameters.

When using MVI, this would require evaluating the results for every single character in order to get the latest list, even though we only care about the latest user input.

Theoretically with reactive operators, we could use either debounce (to reduce the number of requests within a given time frame) and switchMap/flatMapLatest, however using a state reducer makes this impossible. You cannot skip state evaluation!

This is why if you use an app where while data is loading, your navigation actions are completely ignored, this is why — they cannot cancel an ongoing request, because their state modelling doesn’t allow it. In MVI apps, if debouncing is added, it’s done by the view, and cannot be an implementation detail of the ViewModel.

Imperative MVI in current trendy inheritance-based MVI frameworks

Pretty much all currently available trendy frameworks feature these same limitations — in fact, if you use an “MVI framework”, this is basically what they do.

They don’t do much else except for you to:

  • store all your state in a single MutableStateFlow/MutableLiveData/BehaviorRelay
  • inherit that field from a base class (so that you become highly coupled to the framework)
  • when edits are made to the state, these “mutations” are strictly serialized, and the state variable is wrapped in synchronized, mutex or lock

This applies for pretty much all popular MVI frameworks — Orbit, Mavericks, Uniflow-kt, Mobius

As this is all a way to implement the Command pattern with an undo stack (command processor).

Undo history in GIMP

It makes sense when you actually need that: a list of previous executed operations, so that you can undo them at will. However, this is overhead if you have something like an input form, or just showing a list of data fetched from database and/or the network.

Reclaiming reactivity: evaluating state as a function of inputs

Now if we recognize that we do not need to keep the evaluated results (state + async loaded data + transient state) as a single object in a single field, and we don’t need to make the stream itself stateful, then we can swap out scan (which must return a value synchronously) with combineLatest (which allows a combination of any number of reactive streams, and therefore allows working with asynchronously retrieved results).

using combine instead of scan

Now we can remove using _state.value = _state.value.copy() because our state is always built from the latest evaluated input parameters!

val state = combine(flow1, flow2, flow3) { param1, param2, param3 ->
State(param1, param2, param3)
}.stateIn(coroutineScope, SharingStarted.WhileSubscribed(5000), State())

If we need debouncing for the values coming from flow1, we can add that. If we need filtering on flow2, we can add that. If we need to load data asynchronously from the database by flow3, we can add that.

We’ve reclaimed reactivity! All we had to do is abandon what MVI frameworks force on you through their inheritance-based APIs. We can build reactive UI state with either LiveData (and SavedStateHandle), or RxJava’s BehaviorRelay, or MutableStateFlow, but in theory even with Databinding’s observable computed properties.

The option was always there, all we had to do was, uh, not do imperative MVI?

Conclusion

Example for reactively evaluated UI state

Now we’re able to use your state to evaluate data asynchronously, and even state persistence across process death is trivial.

Once we recognize that we never needed to build a state reducer, and instead we can create a state combiner, we can finally use reactive frameworks in a reactive way using combine — instead of being forced to implement a scan operation where all events must be processed in a strictly sequential manner (as otherwise, we could get race conditions).

However, using combine, you always see the latest values of the inputs, which means that race conditions are impossible (you don’t depend on previous values to evaluate your current state). If ensuring correct execution was as simple as not having to read the previous evaluated value, why has it been done for over 6 years?

As I’ve posted about the downsides of imperative MVI before, and even gave a talk on how to turn state management reactive instead, I won’t be the person to answer that. Maybe third time’s the charm? 😅


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK