The Trifecta of State — Why Separating UI from State Management is a Great Idea — Part 1/4

Christian Gaisl
5 min readSep 20, 2023

--

Whether you’re a seasoned developer or just starting out, this series of articles has something for you. For the newcomers, it’s a solid foundation. For experienced developers, it might put into words what you've intuitively been practicing all along.

In this article, we will explore the high-level concepts of state management, specifically in mobile apps. The ideas in this article are mostly platform/framework agnostic, so we can use them on iOS, Android, or whatever platform/framework we prefer. In upcoming articles, I’ll go into more detail with sample implementations for different platforms. The next articles in this four-part series will go into the application of these concepts using Jetpack Compose, SwiftUI, and Kotlin Multiplatform.

The Trifecta of State Management.

First, we need to look at state management in the context of mobile applications or applications with a graphical user interface (GUI) in general.

We can break down a typical app screen into three things:

  • State: The Current UI State
    - Includes data (think: text, images, or user profile data) loaded from external sources, user input, or loading/error states.
  • Actions: Triggered by User Interactions
    - Includes button presses, text input, or gestures.
  • Side Effects: One-shot Events
    - Includes vibrate phone, dial phone number, or open share dialog.
An example app screen with annotations for State, Actions, and Side Effects

State-Consumer and State-Modifier Interaction

State Consumer:

Produces: Actions
Consumes: State, Side Effects

State Modifier:

Produces: State, Side Effects
Consumes: Actions

Splitting UI from state management means splitting our UI code into state-consuming and state-modifying parts. In practice, the state-consuming part is usually called “View”, and the state-modifying part is usually called “ViewModel”, “Controller”, “Presenter”, or “Provider”. There are different nuances involved when connecting the two parts. However, the fundamental concept stays the same and applies to any platform or framework.

The four reasons why splitting up our code in such a manner is a good idea.

1. Unidirectional Data Flow — Single Source of Truth

State can only ever come from the state-modifying part. The state-consuming part is merely consuming state. If the state is not what we expect it to be, there’s only one location where things could have gotten wrong. This makes it much easier to reason with and trace an error to its root. This is especially helpful when working in a team with other people’s code since state modifying code has a place where it’s “supposed to be,” making it easy to find.

2. Testability

Testing is much easier, with the state-modifying part being separate from the state-consuming part. The state-modifying part can be implemented using little to no dependencies on the UI framework we are using. This makes unit testing a breeze. In our test, we instantiate the state-modifying class, trigger some actions, and check the resulting state and/or side effects. Here’s an example of how such a test would look like using Kotlin:

// Code to be tested

interface MyDatabase {
suspend fun loadUsername(): String
}

data class MyState(
val username: String? = null,
)

sealed class MySideEffects {
object Vibrate : MySideEffects()
}

class MyStateModifier(
val myDatabase: MyDatabase
) {
val state = MutableStateFlow(MyState())
val sideEffects = MutableSharedFlow<MySideEffects>()

suspend fun loadUsername() {
state.value = state.value.copy(username = myDatabase.loadUsername())
sideEffects.emit(MySideEffects.Vibrate)
}
}

// Test

class ExampleStateModifierTest {
@Test
fun testLoadusername() = runTest {
val fakeDatabase = object : MyDatabase {
override suspend fun loadUsername(): String {
return "Christian"
}
}

val myStateModifier = MyStateModifier(fakeDatabase)

// Capture side effects
myStateModifier.sideEffects.test {

// Trigger action
myStateModifier.loadUsername()

// Assert state
assertEquals("Christian", myStateModifier.state.value.username)

// Assert side effects
assertEquals(MySideEffects.Vibrate, awaitItem())

}
}
}

3. Previews

Two of the most prominent UI frameworks on mobile, Compose and SwiftUI, rely on using previews during the development workflow. Having our UI code split up into a state-consuming part and a state-modifying part makes it really easy to create previews. Creating a mock state allows us to preview our state-consuming part in whatever state we like. Watch out for my upcoming articles on how to do that, specifically in Compose and SwiftUI.

4. Multiplatform & Kotlin Multiplatform

This concept of splitting our UI code into two parts can be applied to any platform/framework we may want to use in the future. This makes picking up a new platform as easy as learning a little bit of syntax and figuring out the preferred UI lifecycle libraries for that platform.

Kotlin Multiplatform

The state-modifying code can also be reused across multiple platforms/frameworks with Kotlin Multiplatform. If we are building the same app, or even just the same screen, on multiple platforms, we can re-use our state modifying code by writing it in Kotlin. This can be a huge efficiency gain in many use cases. Some of the advantages are:

  • Functionality only has to be written and maintained once
  • Tests only have to be written and maintained once
  • Bug fixes and change requests can be done for all platforms at once
  • Keeps platforms in sync

Every platform/framework has its own approach when it comes to managing asynchronous operations and UI component lifecycles. We might have to introduce a translation layer depending on the platforms involved. Luckily for us, someone else has already done the legwork. Rickclephas has built a library that makes sharing ViewModels between iOS and Android apps almost as simple as using them natively: https://github.com/rickclephas/KMM-ViewModel.

I have personally chosen this approach in some of my production code, and, depending on the project, it makes a lot of sense.

Conclusion

In this article, we explored some of the advantages of splitting up our code into a state-consuming and a state-modifying part on a high level. These concepts are platform/framework agnostic. In fact, once internalized, they can make it really easy to pick up another framework.

In the next article of this series, we will discuss an example utilizing the concepts in this article on Android using Jetpack Compose. In my upcoming articles, I’ll cover even more platforms. Thanks 👋

Enjoyed this article? Feel free to give me a clap 👏, write a comment, and follow me here on Medium to catch all my latest articles. Thanks 👋

--

--