State management is one of the most important concepts to understand when building Android apps with Jetpack Compose. Unlike the traditional View system where you manually update UI elements, Compose recomposes your UI automatically when state changes. This is powerful, but it requires a different way of thinking about how state flows through your application.
In this guide, I'll walk you through the state management patterns that will help you build Compose apps that are maintainable, performant, and a pleasure to work with.
Understanding State in Compose
In Compose, state is any value that can change over time. When state changes, Compose automatically recomposes the parts of the UI that depend on that state. This means you do not need to manually update TextViews, hide and show views, or manage adapters. You just declare what the UI should look like for a given state, and Compose handles the rest.
State is Eventual
An important concept to understand is that state in Compose is eventual. When you update state, Compose doesn't update the UI immediately. Instead, it schedules a recomposition for the next frame. This is usually fast enough that users don't notice, but it's important to understand that state changes are asynchronous.
Mutable State vs Immutable State
Compose works best with immutable state. When you update state, you should create a new state object rather than modifying the existing one. This makes it easier for Compose to detect changes and recompose efficiently.
State Hoisting: The Core Pattern
State hoisting is the most important pattern in Compose state management. It means moving state to the caller of a composable function so that the composable itself is stateless. This makes composables reusable and easier to test.
Why State Hoisting Matters
Consider this example:
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Count: $count")
}
}
This works, but Counter manages its own state, making it hard to reuse. What if you want to use Counter in different screens with different initial values? What if you want to save the count when the user rotates the device?
The Better Approach
Hoist the state to make the composable stateless:
@Composable
fun Counter(count: Int, onIncrement: () -> Unit) {
Button(onClick = onIncrement) {
Text("Count: $count")
}
}
@Composable
fun CounterScreen() {
var count by remember { mutableStateOf0) }
Counter(count = count, onIncrement = { count++ })
}
Now Counter is a stateless composable that can be reused anywhere. It doesn't care where the count comes from or what happens when the button is clicked. It just displays the count and calls onIncrement when the button is pressed.
State Hoisting Rules
A good rule of thumb: if a composable needs to change state, hoist that state to the nearest common ancestor. The composable should receive the state as a parameter and expose events as callback functions.
Using ViewModel for Business State
For state that needs to survive configuration changes or be shared across multiple screens, use a ViewModel. The ViewModel holds business state and exposes it to the UI using StateFlow or LiveData.
ViewModel with StateFlow
StateFlow is the modern choice for Compose. It integrates seamlessly with Compose through collectAsState():
class CounterViewModel : ViewModel() {
private val _count = MutableStateFlow(0)
val count: StateFlow<Int> = _count.asStateFlow()
fun increment() {
_count.value++
}
}
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
val count by viewModel.count.collectAsState()
Counter(count = count, onIncrement = viewModel::increment)
}
The ViewModel survives configuration changes, so the count is preserved when the user rotates the device. The UI observes the state using collectAsState() and recomposes automatically when the state changes.
When to Use ViewModel
Use ViewModel for:
- State that needs to survive configuration changes
- State that is shared across multiple screens
- Business logic that doesn't belong in the UI
- Data that comes from repositories or use cases
Don't use ViewModel for local UI state that doesn't need to be shared or preserved. Use remember for that.
Choosing the Right Observable Type
Compose supports several types of observable state. The right choice depends on your use case.
MutableState
MutableState is the simplest option and works well for local UI state. Use it with remember to create state that is scoped to a composable's lifecycle:
@Composable
fun TextField() {
var text by remember { mutableStateOf("") }
TextField(
value = text,
onValueChange = { text = it }
)
}
StateFlow
StateFlow is the best choice for ViewModel state. It integrates seamlessly with Compose through collectAsState(), and it supports coroutines and flow transformations.
LiveData
LiveData is also supported through the collectAsState() extension function. If you are migrating an existing app that uses LiveData, you can continue using it with Compose.
Avoiding Unnecessary Recomposition
Recomposition is Compose's mechanism for updating the UI when state changes. While recomposition is efficient, unnecessary recompositions can still cause performance problems.
Use Remember
Use remember to cache the results of expensive computations. If a computation depends on state that has not changed, remember returns the cached result instead of recomputing:
@Composable
fun ExpensiveComputation(items: List<Item>, filter: String) {
val filteredItems = remember(items, filter) {
items.filter { it.name.contains(filter, ignoreCase = true) }
}
LazyColumn {
items(filteredItems) { item ->
Text(item.name)
}
}
}
Use DerivedStateOf
Use derivedStateOf to create state that depends on other state. derivedStateOf only triggers recomposition when its dependencies change, not on every recomposition:
@Composable
fun TodoList(todos: List<Todo>) {
val completedCount by remember {
derivedStateOf { todos.count { it.isCompleted } }
}
Text("$completedCount / ${todos.size} completed")
}
Managing Complex State
For complex state with multiple related values, use a data class to group them together. This reduces the number of state variables and makes state updates atomic.
data class UserState(
val name: String = "",
val email: String = "",
val isLoading: Boolean = false,
val error: String? = null
)
@Composable
fun UserProfileScreen() {
var state by remember { mutableStateOf(UserState()) }
// Update state using copy()
state = state.copy(name = "Alice", isLoading = false)
}
Testing State Management
One of the benefits of Compose's state management approach is that it makes testing easier. Stateless composables can be tested by passing different state values and verifying the output. ViewModels can be tested independently of the UI by observing their state flows.
@Test
fun testCounterViewModel() {
val viewModel = CounterViewModel()
assertEquals(0, viewModel.count.value)
viewModel.increment()
assertEquals(1, viewModel.count.value)
}
Frequently Asked Questions
When should I use remember vs ViewModel?
Use remember for local UI state that doesn't need to survive configuration changes. Use ViewModel for state that needs to survive configuration changes or be shared across screens.
Should I use StateFlow or LiveData?
StateFlow is the modern choice and integrates seamlessly with Compose. LiveData still works, especially if you're migrating an existing app. For new projects, use StateFlow.
How do I pass state between screens?
Use a shared ViewModel scoped to the navigation graph, or pass state as arguments through the navigation controller. For complex state that needs to be shared across many screens, consider using a state holder or repository.
What's the difference between remember and rememberSaveable?
rememberSaveable is like remember, but it also saves the state across process death and configuration changes. Use it for state that needs to be preserved, like form inputs or scroll position.
How do I handle one-time events like snackbars?
Use a channel or event wrapper to handle one-time events. StateFlow is designed for persistent state, not one-time events. Use a Channel or a sealed class wrapper to emit events that should only be handled once.
The Bottom Line
State management in Jetpack Compose is fundamentally different from the traditional View system. Hoist state to make composables reusable, use ViewModel for business state that survives configuration changes, choose the right observable type for your use case, avoid unnecessary recomposition, and test your state management logic independently. These practices will help you build Compose apps that are maintainable, performant, and a pleasure to work with.
Remember: good state management is the foundation of good Compose architecture. Invest the time to understand these patterns, and you'll build better apps with less bugs and less frustration.