State Management in Jetpack Compose: ViewModel vs. Remember Function

Kerry Bisset
Stackademic
Published in
10 min readFeb 26, 2024

--

Have you ever found yourself pondering the best practices for managing state in your applications? With the advent of Jetpack Compose, how has your approach to state management transformed? Jetpack Compose, Google’s modern toolkit for building native UIs, has indeed revolutionized how developers craft interfaces on Android. Yet, as we embrace this declarative UI paradigm, we encounter new challenges and decisions, particularly around state management. The dilemma of where to place the view state — be it in the ViewModel, adhering to the MVVM (Model-View-ViewModel) and MVI (Model-View-Intent) patterns, or within the view using the remember function—presents a nuanced decision every developer must ponder.

State management is important in creating dynamic, responsive user interfaces that feel alive and interactive. It’s the backbone of your application’s UI logic, dictating everything from user inputs to screen transitions. Jetpack Compose, with its reactive programming model, requires a thoughtful approach to how and where we manage state to ensure our apps are not only efficient but also maintainable and scalable.

In this article, we will explore the merits of both storing state in the ViewModel and utilizing the remember function within the view. We'll explore the concepts, compare methodologies, and provide actionable insights to guide you in making the most appropriate choice for managing state in your Jetpack Compose applications.

Clarifying the Core Issue: Scroll State Management in MVI with Jetpack Compose

One of the practical scenarios that bring the dilemma of state management into a simple problem we can focus on involves scrolling in a list. Imagine an application where the user can scroll through a long list of items. The user navigates away from the list to view details of an item and expects to return to the same scroll position upon returning to the list. This user experience expectation necessitates a decision: where should the scroll state live?

In traditional development patterns, including MVVM and MVI, there’s a clear separation of concerns. The ViewModel is tasked with handling the logic and state of the application, while the view layer (UI) is responsible for presentation and user interactions. However, Jetpack Compose’s reactive UI paradigm, coupled with its architecture components, introduces nuances in how we approach state management, particularly with the remember function and the ViewModel.

The ViewModel Approach

In an MVI (Model-View-Intent) pattern, the ViewModel acts as the hub of your application’s logic, processing user intents, updating the state, and rendering the UI based on the current state. Ideally, keeping the scroll state in the ViewModel ensures that the state is preserved across configuration changes, such as screen rotations, and is consistent with the single source of truth principle. This approach aligns with the intention to keep the logic centralized and the UI layer as dumb as possible, merely reflecting the state provided by the ViewModel.

However, Jetpack Compose’s declarative nature and the remember function offer an alternative that seems to challenge this traditional separation, particularly for transient UI state like scroll positions.

The Remember Function in the View

Jetpack Compose’s remember function is designed to preserve state across recompositions, making it tempting to use for managing scroll state directly within the UI layer. This approach provides easy access to the state exactly where it's needed, simplifying the code for scenarios where the state doesn't need to survive beyond the lifecycle of the composable.

But, here lies the core issue when applying MVI with Jetpack Compose: If we opt to remember the scroll state in the view, we might inadvertently blur the lines of separation between the ViewModel and the view. MVI's architecture is predicated on the idea that all logic, including state management, should be centralized within the ViewModel. Using remember for scroll state forces a split in where logic and state management occur, potentially complicating the architecture.

Navigating the Divide

The challenge, then, is to navigate this divide without compromising the architectural integrity of our applications. On one hand, adhering strictly to MVI principles would dictate that all state, including scroll position, should be managed by the ViewModel. On the other, the practicalities and efficiencies offered by remember for certain types of UI state are undeniable.

Managing BottomSheetState with ViewModel in Jetpack Compose

When adopting the ViewModel to manage scroll state in a Jetpack Compose application, especially within the MVI (Model-View-Intent) architecture, the process becomes an integration of UI state management and business logic handling. This section will guide you through an approach where the ViewModel holds the bottom sheet state, illustrating how interactions from the UI layer can modify this state effectively.

Step 1: Define Bottom Sheet State in ViewModel

The first step is to define a property in your ViewModel to hold the bottom sheet state. We are going to take some from the example of this article.

class PlayerListViewModel(
iPrimitiveDataStoreProvider: IPrimitiveDataStoreProvider,
iComposableStateHandler: IComposableStateHandler,
density: Density,
private val composeScope: CoroutineScope,
) : StatedComposeViewModel(iPrimitiveDataStoreProvider, iComposableStateHandler, id = "playerList") {

companion object {
private const val KEY_BOTTOM_SHEET_STATE = "bottomSheetState"
private const val KEY_LIST = "list"
private const val KEY_TAB_SELECTED = "tabSelected"
}

internal val tabs: List<String> = listOf("All", "Offense", "Defense", "Special Teams")

internal val selectedTab = mutableIntStateOf(
primitiveData?.getInt(
KEY_TAB_SELECTED,
) ?: 0
)

internal val bottomSheetState = SheetState(
skipPartiallyExpanded = false,
density = density,
initialValue = SheetValue.values()[primitiveData?.getInt(
KEY_BOTTOM_SHEET_STATE,
) ?: SheetValue.Hidden.ordinal],
skipHiddenState = false
)

private val allPlayers = ArrayList(primitiveData?.getString(KEY_LIST)?.let {
Json.decodeFromString(ListSerializer(Player.serializer()), it)
} ?: emptyList()
)

internal val playerList = mutableStateOf(determinePlayerList())

internal val currentPlayerEdited = mutableStateOf(
if (primitiveData?.getInt(
KEY_BOTTOM_SHEET_STATE,
) == SheetValue.Expanded.ordinal
) {
Player("", "", setOf())
} else {
null
}
)

override fun retainState(): PrimitiveDataStore {
return PrimitiveDataStore().apply {
putInt(KEY_BOTTOM_SHEET_STATE, bottomSheetState.currentValue.ordinal)
putString(KEY_LIST, Json.encodeToString(ListSerializer(Player.serializer()), allPlayers))
}
}

internal fun onInteraction(interactions: PlayerListInteractions) {
when (interactions) {
is PlayerListInteractions.ShowAddPlayerForm -> {
composeScope.launch {
currentPlayerEdited.value = Player("", "", setOf())
bottomSheetState.expand()
}
}

is PlayerListInteractions.PerformFormFinished -> {
when (interactions.formResult) {
is PlayerFormResult.FormFinished -> {
allPlayers.add(interactions.formResult.player)
playerList.value = determinePlayerList()
}

PlayerFormResult.Cancelled -> {
// No implementation needed.
}
}

composeScope.launch {
bottomSheetState.hide()
currentPlayerEdited.value = null
}
}

is PlayerListInteractions.SelectTab -> {
selectedTab.intValue = interactions.index
playerList.value = determinePlayerList()
}
}
}

private fun determinePlayerList(): List<Player> {
return when (selectedTab.intValue) {
Area.Offense.ordinal + 1 -> {
allPlayers.filter { it.areas.contains(Area.Offense) }
}

Area.Defense.ordinal + 1 -> {
allPlayers.filter { it.areas.contains(Area.Defense) }
}

Area.SpecialTeams.ordinal + 1 -> {
allPlayers.filter { it.areas.contains(Area.SpecialTeams) }
}

else -> {
allPlayers
}
}
}
}

Step 2: Respond to State in Composable

In your Composable function, observe the bottom sheet state from the ViewModel and use it to update ModalBottomSheet.

fun PlayerListView(vm: PlayerListViewModel) {
Scaffold(
floatingActionButton = {
FloatingActionButton({
vm.onInteraction(PlayerListInteractions.ShowAddPlayerForm)
}) {
Icon(imageVector = Icons.Filled.Add, contentDescription = "Add Person")
}
},

) { contentPadding ->
Column(modifier = Modifier.padding(contentPadding)) {

TabRow(
selectedTabIndex = vm.selectedTab.intValue,
modifier = Modifier.fillMaxWidth(),
tabs = {
vm.tabs.forEach {
Tab(
text = { Text(text = it) },
selected = vm.selectedTab.intValue == vm.tabs.indexOf(it),
onClick = {
vm.onInteraction(PlayerListInteractions.SelectTab(vm.tabs.indexOf(it)))
}
)
}
})

if (vm.currentPlayerEdited.value != null) {
ModalBottomSheet(
onDismissRequest = {
vm.onInteraction(PlayerListInteractions.PerformFormFinished(PlayerFormResult.Cancelled))
},
sheetState = vm.bottomSheetState,
windowInsets = WindowInsets(0)
) {
StatedComposableViewModel(
primitiveDataStoreProvider = AndroidStateHandler,
composableStateHandler = AndroidStateHandler,
factory = { iPrimitiveDataStoreProvider: IPrimitiveDataStoreProvider, iComposableStateHandler: IComposableStateHandler ->
PlayerFormViewModel(
iPrimitiveDataStoreProvider,
iComposableStateHandler,
)
},
key1 = vm.currentPlayerEdited
) { formViewModel ->
PersonFormUi(formViewModel, vm)
}
}
}

LazyColumn {
vm.playerList.value.forEach { person ->
item {
Card(modifier = Modifier.padding(8.dp)) {
Text(
text = person.firstName,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
)
Text(
text = person.lastName,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
}
}
}
}
}

Step 3: Trigger State Update from ViewModel

The FAB in our example triggers an interaction from the user or “user intent” which then causes the bottom sheet state to change.

internal fun onInteraction(interactions: PlayerListInteractions) {
when (interactions) {
is PlayerListInteractions.ShowAddPlayerForm -> {
composeScope.launch {
currentPlayerEdited.value = Player("", "", setOf())
bottomSheetState.expand()
}
}
}

Examining the Construction of the View Model

In the Model-View-Intent (MVI) architecture, managing state and handling user interactions in a predictable, unidirectional data flow is paramount. However, Jetpack Compose introduces concepts like rememberCoroutineScope and LocalDensity, which are scoped to the composable's lifecycle and context. These concepts present a unique challenge when integrating with MVI, as they do not fit neatly into the traditional view or view model categories. They are, instead, scoped concepts essential for managing UI behavior and appearance. Let's explore how these scoped concepts are integrated within an MVI architecture through a practical example involving the PlayerListViewModel.

Scoped Concepts in MVI

The MVI architecture is typically agnostic of the Android framework, focusing on pure Kotlin implementations for predictability and testability. However, Compose’s declarative nature and the need for lifecycle-aware operations necessitate a bridge between MVI’s pure approach and Android’s scoped concepts.

  1. rememberCoroutineScope is used to create a coroutine scope tied to the composable's lifecycle. If you use MainScope, you will get the following crash.
 A MonotonicFrameClock is not available in this CoroutineContext. Callers should supply an appropriate MonotonicFrameClock using withContext.
  1. LocalDensity provides access to the screen density, crucial for making UI adjustments based on the device's display characteristics. This allows for density-independent pixel calculations, ensuring the UI scales appropriately across different screen sizes and resolutions.

Integrating Scoped Concepts with ViewModel

In the provided example, the PlayerListViewModel is instantiated within a StatedComposableViewModel, receiving both density and coroutineScope as parameters. This setup highlights an approach to incorporating scoped concepts within the ViewModel, facilitating operations that are inherently dependent on the composable's context and lifecycle:

val coroutineScope = rememberCoroutineScope()
val density = LocalDensity.current
StatedComposableViewModel(
primitiveDataStoreProvider = AndroidStateHandler,
composableStateHandler = AndroidStateHandler,
factory = { iPrimitiveDataStoreProvider: IPrimitiveDataStoreProvider, iComposableStateHandler: IComposableStateHandler ->
PlayerListViewModel(
iPrimitiveDataStoreProvider,
iComposableStateHandler,
density,
coroutineScope
)
}
) { vm ->
PlayerListView(vm)
}

When integrating scoped concepts such as density and coroutineScope from Jetpack Compose into the architecture of an application, particularly within patterns like MVI (Model-View-Intent), it's important to distinguish these elements from traditional view state. Despite originating from the composable layer, both density and coroutineScope serve specific, scoped purposes that extend beyond the conventional definition of view state. These scoped concepts are instrumental in facilitating lifecycle-aware operations and adapting UI rendering to the device context but do not represent the mutable state of the UI that typically comprises user interactions, data display, or navigation states. Their inclusion in architectural considerations, especially when passed into ViewModels, is a pragmatic adaptation to Compose's declarative nature and does not conflate with the core principles of managing view state within an application's architecture.

Managing BottomSheetState within the Composable

Managing bottom sheet state locally within a Composable involves using state hoisting and event handling mechanisms that Compose provides. This approach keeps the UI logic close to the UI components, leveraging remember and mutableStateOf to track the visibility and state of the bottom sheet directly within the Composable function. Here’s how this can align with MVI:

fun PlayerListView(vm: PlayerListViewModel) {
// Keeping state in the composable
val bottomSheetState = rememberModalBottomSheetState()
val coroutineScope = rememberCoroutineScope()
Scaffold(
floatingActionButton = {
FloatingActionButton({
vm.onInteraction(PlayerListInteractions.ShowAddPlayerForm).also {
coroutineScope.launch {
bottomSheetState.show()
}
}
}) {
Icon(imageVector = Icons.Filled.Add, contentDescription = "Add Person")
}
},

) { contentPadding ->
Column(modifier = Modifier.padding(contentPadding)) {

TabRow(
selectedTabIndex = vm.selectedTab.intValue,
modifier = Modifier.fillMaxWidth(),
tabs = {
vm.tabs.forEach {
Tab(
text = { Text(text = it) },
selected = vm.selectedTab.intValue == vm.tabs.indexOf(it),
onClick = {
vm.onInteraction(PlayerListInteractions.SelectTab(vm.tabs.indexOf(it)))
}
)
}
})

if (vm.currentPlayerEdited.value != null) {
ModalBottomSheet(
onDismissRequest = {
vm.onInteraction(PlayerListInteractions.PerformFormFinished(PlayerFormResult.Cancelled))
},
sheetState = bottomSheetState,
windowInsets = WindowInsets(0)
) {
StatedComposableViewModel(
primitiveDataStoreProvider = AndroidStateHandler,
composableStateHandler = AndroidStateHandler,
factory = { iPrimitiveDataStoreProvider: IPrimitiveDataStoreProvider, iComposableStateHandler: IComposableStateHandler ->
PlayerFormViewModel(
iPrimitiveDataStoreProvider,
iComposableStateHandler,
)
},
key1 = vm.currentPlayerEdited
) { formViewModel ->
PersonFormUi(formViewModel, vm)
}
}
}

LazyColumn {
vm.playerList.value.forEach { person ->
item {
Card(modifier = Modifier.padding(8.dp)) {
Text(
text = person.firstName,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
)
Text(
text = person.lastName,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
}
}
}
}
}
  1. Instead of encapsulating the bottom sheet state within the ViewModel, the state is hosted at the top-level Composable that displays the bottom sheet. This Composable then passes down the state and event handlers to its child composables as needed, which can get convoluted.
  2. User interactions with the bottom sheet that might affect the application state are propagated back to the ViewModel as intents. For example, confirming a selection made in the bottom sheet would trigger an intent to update the application state, adhering to the MVI cycle of intent, model (state), and view (UI).
  3. Despite the bottom sheet state being managed locally, the unidirectional data flow principle of MVI is maintained. The ViewModel processes intents and updates the overall application state, which then reflects back to the UI. The local bottom sheet state simply becomes a part of the larger state management strategy, ensuring consistency and predictability in the application’s behavior.

Navigating the Trade-offs of Local State Management

While managing state locally within Composables, such as the bottom sheet state in a Jetpack Compose application, offers convenience and leverages the declarative nature of Compose, it’s essential to recognize the trade-offs of this approach, especially when adhering to the Model-View-Intent (MVI) architecture. The temptation to host state at the Composable level for its ease of use and direct access can indeed make certain UI behaviors, like managing scroll states or temporary UI states, more straightforward and responsive. However, this strategy introduces potential side effects that can impact the application’s architecture and future scalability.

The Challenge of Ensuring a Single Source of Truth

One of the foundational principles of MVI is maintaining a single source of truth for the application’s state, typically centralized in the ViewModel. This approach ensures that the state is consistent across the application, easily testable, and decoupled from the UI layer. By managing state locally within Composables, we risk fragmenting the application’s state, leading to challenges in maintaining consistency, especially as the application grows and evolves.

Implications for Application Expansion and Consistency

Local state management, while offering immediate benefits for UI responsiveness and simplicity, may limit the application’s capacity for expansion and maintaining consistency. As applications scale, the need for a unified state management strategy becomes increasingly rationalized. A centralized approach, with the ViewModel acting as the hub for state and logic, facilitates this scalability by ensuring that all parts of the application react to state changes uniformly, enhancing the overall user experience.

Balancing Convenience with Architectural Integrity

The decision to manage the bottom sheet state, or any transient UI state, at the Composable level should be weighed against these considerations. It’s a balance between the convenience and immediate benefits of local state management and the long-term advantages of adhering to a centralized state management approach. While managing scroll states and similar UI behaviors locally can be less cumbersome and more “fun,” it’s essential to consider the broader architectural implications.

Developers must critically assess their state management strategies, keeping in mind the application’s current needs and future growth. In many cases, adopting a hybrid approach — managing transient, UI-specific states locally while keeping the core application state centralized in the ViewModel — can offer a practical compromise. This strategy allows for the responsive and dynamic UI behaviors that Compose enables, while still maintaining the application’s architectural integrity, ensuring consistency, and facilitating expansion.

Stackademic 🎓

Thank you for reading until the end. Before you go:

--

--