Jetpack Compose State Management: Combining State Holders with ViewModels

In Jetpack Compose, managing state effectively is crucial for building robust and maintainable applications. ViewModels, from Android Architecture Components, provide a lifecycle-aware way to manage UI-related data. Combining State Holders with ViewModels enhances the structure, testability, and separation of concerns in your Compose applications.

Understanding State in Jetpack Compose

In Jetpack Compose, state refers to the data that changes over time and affects your app’s UI. Compose is declarative, meaning the UI reflects the current state. Efficient state management is essential for creating dynamic and interactive applications.

What are State Holders?

State Holders are classes that hold and manage the state of a Composable function. They centralize the state logic, making Composables simpler and easier to test. State Holders can encapsulate complex state management logic, event handling, and transformations.

What are ViewModels?

ViewModels are classes designed to store and manage UI-related data in a lifecycle-conscious way. They survive configuration changes, such as screen rotations, and help keep your UI code clean by separating concerns.

Why Use State Holders with ViewModels?

  • Lifecycle Awareness: ViewModels ensure state survives configuration changes.
  • Separation of Concerns: State Holders manage state logic, keeping Composables clean.
  • Testability: Makes state management logic easier to test.
  • Reusability: State Holders can be reused across different Composables.
  • Maintainability: Centralizing state logic makes the code easier to understand and maintain.

How to Implement State Holders with ViewModels in Jetpack Compose

Let’s walk through how to implement State Holders with ViewModels using practical examples.

Step 1: Add Dependencies

Ensure you have the necessary dependencies in your build.gradle file:

dependencies {
    implementation("androidx.core:core-ktx:1.12.0")
    implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2")
    implementation("androidx.activity:activity-compose:1.8.2")
    implementation(platform("androidx.compose:compose-bom:2023.08.00"))
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-graphics")
    implementation("androidx.compose.ui:ui-tooling-preview")
    implementation("androidx.compose.material3:material3")
    testImplementation("junit:junit:4.13.2")
    androidTestImplementation("androidx.test.ext:junit:1.1.5")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.6.0")
    androidTestImplementation(platform("androidx.compose:compose-bom:2023.08.00"))
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
    debugImplementation("androidx.compose.ui:ui-tooling")
    debugImplementation("androidx.compose.ui:ui-test-manifest")
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2")
}

Step 2: Create a ViewModel

Create a ViewModel that holds and manages the state using MutableState or MutableLiveData.

import androidx.lifecycle.ViewModel
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

class CounterViewModel : ViewModel() {
    var count by mutableStateOf(0)
        private set

    fun increment() {
        count++
    }

    fun decrement() {
        count--
    }
}

Step 3: Create a State Holder

A State Holder is optional but can be very helpful for complex logic. If you choose not to use it, your ViewModel will directly expose the states and event handling methods.

class CounterStateHolder(private val viewModel: CounterViewModel) {
    val count: Int get() = viewModel.count

    fun onIncrement() {
        viewModel.increment()
    }

    fun onDecrement() {
        viewModel.decrement()
    }
}

Step 4: Create a Composable

Use the ViewModel in a Composable function to display and update the UI.


import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp

@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
    val stateHolder = CounterStateHolder(viewModel)

    CounterContent(
        count = stateHolder.count,
        onIncrement = stateHolder::onIncrement,
        onDecrement = stateHolder::onDecrement
    )
}

@Composable
fun CounterContent(count: Int, onIncrement: () -> Unit, onDecrement: () -> Unit) {
    Column {
        Text(text = "Count: $count", fontSize = 20.sp)
        Button(onClick = onIncrement) {
            Text(text = "Increment")
        }
        Button(onClick = onDecrement) {
            Text(text = "Decrement")
        }
    }
}

@Preview(showBackground = true)
@Composable
fun CounterScreenPreview() {
    CounterScreen()
}

Example 2: Managing a List with State Holders and ViewModels

Let’s create an example of managing a list of items using State Holders with ViewModels.

Step 1: Define Data Class

data class TodoItem(val id: Int, val task: String, var isCompleted: Boolean = false)

Step 2: Create a ViewModel

import androidx.lifecycle.ViewModel
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.toMutableStateList

class TodoViewModel : ViewModel() {
    private val _todoItems = mutableStateListOf<TodoItem>()
    val todoItems: List<TodoItem> = _todoItems

    init {
        _todoItems.addAll(
            listOf(
                TodoItem(1, "Buy groceries"),
                TodoItem(2, "Do laundry"),
                TodoItem(3, "Walk the dog")
            )
        )
    }

    fun addTodo(task: String) {
        val newId = (_todoItems.maxOfOrNull { it.id } ?: 0) + 1
        _todoItems.add(TodoItem(newId, task))
    }

    fun removeTodo(item: TodoItem) {
        _todoItems.remove(item)
    }

    fun toggleTodoCompletion(item: TodoItem) {
        val index = _todoItems.indexOf(item)
        if (index != -1) {
            _todoItems[index] = _todoItems[index].copy(isCompleted = !item.isCompleted)
        }
    }
}

Step 3: Create a State Holder

class TodoStateHolder(private val viewModel: TodoViewModel) {
    val todoItems: List<TodoItem> get() = viewModel.todoItems

    fun onAddTodo(task: String) {
        viewModel.addTodo(task)
    }

    fun onRemoveTodo(item: TodoItem) {
        viewModel.removeTodo(item)
    }

    fun onToggleTodoCompletion(item: TodoItem) {
        viewModel.toggleTodoCompletion(item)
    }
}

Step 4: Create a Composable


import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun TodoScreen(viewModel: TodoViewModel = viewModel()) {
    val stateHolder = TodoStateHolder(viewModel)
    val (text, setText) = remember { mutableStateOf("") }

    Column(modifier = Modifier.padding(16.dp)) {
        Row(verticalAlignment = Alignment.CenterVertically) {
            TextField(
                value = text,
                onValueChange = setText,
                modifier = Modifier.weight(1f),
                placeholder = { Text("Add a todo") }
            )
            Spacer(modifier = Modifier.width(8.dp))
            Button(onClick = {
                if (text.isNotBlank()) {
                    stateHolder.onAddTodo(text)
                    setText("")
                }
            }) {
                Text("Add")
            }
        }

        Spacer(modifier = Modifier.height(16.dp))

        TodoList(
            items = stateHolder.todoItems,
            onRemove = stateHolder::onRemoveTodo,
            onToggleCompletion = stateHolder::onToggleTodoCompletion
        )
    }
}

@Composable
fun TodoList(
    items: List<TodoItem>,
    onRemove: (TodoItem) -> Unit,
    onToggleCompletion: (TodoItem) -> Unit
) {
    items.forEach { item ->
        Row(
            modifier = Modifier.fillMaxWidth(),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.SpaceBetween
        ) {
            Text(text = item.task + (if (item.isCompleted) " (Completed)" else ""))
            Row {
                Button(onClick = { onToggleCompletion(item) }) {
                    Text(if (item.isCompleted) "Mark Incomplete" else "Mark Complete")
                }
                Spacer(modifier = Modifier.width(4.dp))
                IconButton(onClick = { onRemove(item) }) {
                    Icon(imageVector = Icons.Default.Delete, contentDescription = "Delete")
                }
            }
        }
    }
}

Usage

Call TodoScreen() within your activity’s setContent block.

Conclusion

Combining State Holders with ViewModels in Jetpack Compose results in a robust, testable, and maintainable architecture. ViewModels provide lifecycle management, while State Holders encapsulate complex state logic and event handling. This separation of concerns enhances code clarity, simplifies testing, and improves overall application stability.