CMP for Mobile Native Developers — Part. 3: State Holders

Santiago Mattiauda
11 min readAug 6, 2024

--

CMP for Mobile Native Developers: Series

In this series of articles, we will explore the following aspects of Compose Multiplatform:

  • Part 1: Introduction
  • Part 2: UI
  • Part 3: State Holders: we are here.
  • Part 4: Navigation: We will explore Voyager, Precompose, and Navigation Compose.
  • Part 5: Dependency Injection: We will address the topic of dependency injection in Compose Multiplatform applications.
  • Part 6: UI Testing: We will focus on best practices and techniques for testing the user interface in Compose Multiplatform.
  • Part 7: Extra — Using Native Component from CMP: We will examine how to use native components from CMP.

Introduction

In this article, we will address some definitions that we need to keep in mind when implementing the design of the user interface (UI) layer, especially which components we have available in Compose Multiplatform for state management. We will see key concepts such as the definition of UI state, state immutability, and unidirectional data flow. Various libraries and tools for managing UI state are also presented, including Voyager, Precompose ViewModel, and Jetpack ViewModels for both Android implementation in Kotlin Multiplatform and Jetbrains for Compose Multiplatform. Additionally, we discuss how to safely consume data streams in Compose. This guide provides a comprehensive understanding of how to structure and manage UI logic in cross-platform applications.

Let’s start by looking at some architectural concepts to understand the role of one of the components we will see in this article.

UI Layer Architecture

The UI layer in an application should transform and present data so that it is easily renderable, handle user input events, and reflect their effects on the UI data. The guide explains how to implement this layer, covering the definition of UI state, unidirectional data flow (UDF), exposing UI state with observable data types, and implementing a UI that consumes its observable state.

How to Define UI State

The UI state (User Interface) is the information that the application presents to the user, and any change in this state is immediately reflected in the UI. The UI is the visual representation of the UI state.

UI is the result of linking its elements on the screen with the corresponding state.

For example, a UI state is nothing more than a class that represents the properties or information to be displayed in the UI components, for example, to display the following screen.

We will have the UI states reflected in the following way

@Stable
data class HomeUiState(
val isLoading: Boolean = false,
val hasError: Boolean = false,
val data: List<Character> = emptyList(),
)

and the UI elements in this other way.

@Composable
fun HomeScreenContent(
modifier: Modifier = Modifier,
viewModel: HomeViewModel,
onClick: (Character) -> Unit = {},
onFavorite: (Character) -> Unit = {},
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
when {
state.isLoading -> {
LoadingIndicator()
}

state.data.isEmpty() -> {
Center {
Text(
text = "There is no favorite content",
style = MaterialTheme.typography.headlineSmall
)
}
}

else -> {
GridOfCharacters(
modifier = modifier,
characters = state.data,
onClick = onClick,
onFavorite = onFavorite
)
}
}
}

Depending on the state, the UI will present different components, and the one responsible for managing that state will be HomeViewModel, which we will see later what it represents and how to implement it.

To implement a UI layer architecture, we will have to take into account some “rules” that we will see below.

Immutability

The immutability of the UI state provides guarantees about the application’s state, allowing the UI to focus on reading and updating elements without directly modifying the state, avoiding inconsistencies and errors.

💡 Key point: Only the sources or owners of the data should be responsible for updating the data they expose.

Naming conventions in this guide

UI state classes are named according to the functionality of the screen, following the convention of functionality + UiState. For example, a news screen could be named CharactersUiState and a news item in a list, CharacterItemUiState.

How to manage state with a unidirectional data flow

The UI state is an immutable snapshot that can change due to user interactions or other events. To handle these interactions and associated logic, it is recommended to use a mediator and apply a unidirectional data flow, which facilitates the separation of responsibilities and improves the testability of the code.

State containers

State containers are classes responsible for producing the UI state and contain the necessary logic for that task. They can vary in size and scope, from a single widget to an entire screen. A typical example is the use of ViewModel, as in the example we saw that uses CharactersViewModel to manage the UI state.

💡 The ViewModel type is the recommended implementation for managing UI state at the screen level with access to the data layer and surviving configuration changes. ViewModel classes define the event logic in the app and produce an updated state.

The codependency between the UI and its state producer can be modeled in several ways. Since the interaction between the UI and the ViewModel class is understood as an event input and its subsequent state result, this relationship can be represented by a specific diagram.

Diagram of how one-way data flow works in app architecture

The unidirectional flow of data implies that state flows downward and events flow upward. In this pattern, the ViewModel preserves and exposes the state for the UI, handles user events, and updates the state, which is then sent to the UI for rendering. This process repeats with each event that causes a state mutation, and the ViewModel works with repositories or use case classes to obtain and transform data.

State Holders

In order not to associate the ViewModel component with something specific to Android or Jetpack, these components are known as state holders who are basically responsible for communicating the UI elements with the data sources we have in our application and, in turn, being interpreters of the UI logic. To implement these components in Compose Multiplatform, there are several libraries that contemplate the interaction of the UI in Compose and our State Holder, and take into account the communication between our UI state and our components in Compose.

Voyager (Screen Models)

Voyager’s ScreenModel API is part of the module cafe.adriel.voyager:voyager-screenmodel. Similar to Jetpack's ViewModel for Android, it manages UI-related data in a lifecycle-aware manner and survives configuration changes such as screen rotations. Unlike ViewModel, ScreenModel is an Android-independent interface.

Example of Use:

val screenModel = rememberScreenModel { MyScreenModel() }

Instances are created within a Screen using rememberScreenModel and can be differentiated by tags.

In our example, we use an implementation of this interface, which provides us with a mechanism for defining states (something that is commonly repeated when we implement UDF).

class HomeScreenModel(
getAllCharacters: GetAllCharacters,
private val refreshCharacters: RefreshCharacters,
private val addToFavorite: AddToFavorite,
private val removeFromFavorite: RemoveFromFavorites,
) : StateScreenModel<HomeUiState>(HomeUiState()){

// screen model implementation
}

As we can see, our HomeScreenModel extends from StateScreenModel and StateScreenModel has the following implementation to handle the state we have defined.

public abstract class StateScreenModel<S>(initialState: S) : ScreenModel {

protected val mutableState: MutableStateFlow<S> = MutableStateFlow(initialState)
public val state: StateFlow<S> = mutableState.asStateFlow()
}

In addition to this definition of state, we are also provided with an extension property, screenModelScope, which is nothing more than a coroutine scope associated with the lifecycle of our ScreenModel. This is very useful for when we want to launch coroutines in our ScreenModel safely.

And how do we use our ScreenModel in the UI? Well, as we saw, we can use rememberScreenModel, but many times we will not create an instance of our ScreenModel itself in the Composable and if we are using a dependency injection library, in our case Koin in Compose Multiplatform (a topic we will see in another article). Voyager has support for Koin.

val viewModel = koinScreenModel<HomeScreenModel>()

HomeScreenContent(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 16.dp),
viewModel = viewModel,
onClick = {},
onFavorite = viewModel::addToFavorites,
)

including the corresponding dependency, we will have the function koinScreenModel to provide us with an instance of our ScreenModel defined in our dependency catalog.

For more details and examples, visit the following link to the documentation.

You can also check the complete example using Voyager in the following repository

Precompose ViewModel

The PreCompose ViewModel API is similar to Jetpack’s ViewModel, but it is designed for navigation and state management in Compose Multiplatform. The dependency is added in the common module and the ViewModel is defined:

import moe.tlaster.precompose.viewmodel.ViewModel
import moe.tlaster.precompose.viewmodel.viewModelScope

class HomeViewModel(
getAllCharacters: GetAllCharacters,
private val refreshCharacters: RefreshCharacters,
private val addToFavorite: AddToFavorite,
private val removeFromFavorite: RemoveFromFavorites,
) : ViewModel() {

val uiState: StateFlow<HomeUiState> = getAllCharacters()
.map {
HomeUiState(
isLoading = false,
hasError = false,
data = it
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000L),
initialValue = HomeUiState(isLoading = true)
)
// code ..
}

By implementing ViewModel from PreCompose, we are also provided with an extension property, viewModelScope, which is also associated with the lifecycle of the ViewModel.

viewModel() is used in Compose to instantiate ViewModels. For Kotlin/Native, modelClass is used because there are some limitations with Kotlin's redefined types.

val viewModel = viewModel(
modelClass = HomeViewModel::class
) {
HomeViewModel(....)
}

Just like Voyager, PreCompose has support for dependency injection libraries. In our case, we use Koin and for this, PreCompose provides the following function: koinViewModel. Let’s look at the following example:

@Composable
fun HomeScreenRoute() {
val viewModel = koinViewModel(HomeViewModel::class)
HomeScreenContent(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 16.dp),
viewModel = viewModel,
onClick = {},
onFavorite = viewModel::addToFavorites,
)
}

For more details and examples, visit the following link to the documentation.

You can also check the complete example using Voyager in the following repository:

ViewModels Google

Starting from version 2.8.0-alpha03 the lifecycle-* artifacts now support Kotlin Multiplatform. This means that classes such as ViewModel, ViewModelStore, ViewModelStoreOwner, and ViewModelProvider can now be used in your common code.

Originally this artifact was only available for Android targets, but now with official support, you can define and reference ViewModel from your common code.

The ViewModel acts as you would expect, and includes the viewModelScope which is a CoroutineScope that allows you to easily run coroutines from ViewModel.

sourceSets {
commonMain {
dependencies {
implementation("androidx.lifecycle:lifecycle-viewmodel:2.8.0-alpha03")
}
}
}

Just like with the PreCompose ViewModel, we will extend our viewModels from the ViewModel class, but this time from the androidx.lifecycle.ViewModel package

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope

class CharactersViewModel(
getAllCharacters: GetAllCharacters,
private val refreshCharacters: RefreshCharacters,
private val addToFavorite: AddToFavorite,
private val removeFromFavorite: RemoveFromFavorites)
):ViewModel(){

// viewModel code implementation
}

Although it is possible to use this class in Compose Multiplatform, it is recommended to use these ViewModels in Kotlin Multiplatform projects where only our business logic is shared and the UI remains native, using Jetpack Compose on Android or SwiftUI for iOS.

If you want to know more about how to use this type of ViewModels, here is the link to the project: KMP for Mobile Native Developers.

Google has been working on supporting Kotlin Multiplatform in some of its Jetpack solutions. While it is not the focus of this article, you can review its documentation at the following link.

ViewModels JetBrains

While the Jetpack ViewModels library now has support for Kotlin Multiplatform, this library is focused on supporting Kotlin Multiplatform projects as we mentioned earlier.

What about Compose Multiplatform? As we know, the JetBrains team, together with Google, has been working on Multiplatform support for many of the existing solutions in Jetpack, so we will also find support for ViewModels from the Compose Multiplatform side. For this, we will need to import the following dependency.

sourceSets {
commonMain {
dependencies {
implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.8.0")
}
}
}

Where, just like in the other cases, we will extend our viewModels from the ViewModel class, exactly as for the Google ViewModel.

import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope


class HomeViewModel(
getAllCharacters: GetAllCharacters,
private val refreshCharacters: RefreshCharacters,
private val addToFavorite: AddToFavorite,
private val removeFromFavorite: RemoveFromFavorites,
) : ViewModel(){

//ViewModel code implementation.
}

The functionality is the same as what we will find in Jetpack, since the solution was implemented on a fork of the AndroidX project.

For more details and examples, visit the following link to the documentation.

You can also check out the complete example using Jetbrains ViewModels in the following repository:

Consuming flows safely in Compose

As we saw at the beginning of this article, our UI will react to the state changes managed by our ViewModels. Most of the time, we will use Kotlin Flows to represent that flow of communication between the ViewModel and the UI, so it is important that, when the UI picks up those state changes, it does so safely.

Collecting Flows in a lifecycle-aware manner is the recommended way to collect flows in Android. When developing an Android application with Jetpack Compose, the Google team recommends using the collectAsStateWithLifecycle API to collect flows in a lifecycle-aware manner.

collectAsStateWithLifecycle allows your app to conserve application resources when they are not needed, such as when the app is in the background. Keeping resources active can affect the health of users' devices. These resources can include database queries, location updates, or network connections, and database connections.

But what about Compose Multiplatform? The recommendation is equally valid, as this function is available for Compose Multiplatform. As we saw in previous articles, Compose Multiplatform supports Jetpack’s lifecycle API, so configure this dependency:

sourceSets {
commonMain {
dependencies {
implementation("org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.8.4")
}
}
}

We will have this function available and we will be able to apply it in the following way.

@Composable
fun FavoritesScreen(
viewModel: FavoritesViewModel,
onClick: (Character) -> Unit = {},
onFavoriteClick: (Character) -> Unit = {},
) {
val data by viewModel.characters.collectAsStateWithLifecycle()
if (data.isEmpty()) {
Center {
Text(
text = "There is no favorite content",
style = MaterialTheme.typography.headlineSmall
)
}
} else {
ListOfFavorites(
characters = data,
onClick = onClick,
onFavoriteClick = onFavoriteClick
)
}
}

If you want to delve deeper into the use and comparisons of the collectAsStateWithLifecycle function, I leave the following link that addresses the technical aspects of this function.

The end

There are other solutions for implementing state management. In this article, I wanted to review those that are most similar to the currently recommended approach, which would be the implementation of Jetbrains’ ViewModels. This is because the support has been recent and many existing applications used PreCompose or Voyager for their implementations. For my part, I recommend using Jetbrains’ ViewModels as it is a solution native to the Compose Multiplatform ecosystem. In fact, many solutions like Koin are actively providing support for this solution.

Referencias

--

--