State Management in Compose — remember, mutableStateOf & ViewModel
Master state management in Jetpack Compose — remember, mutableStateOf, rememberSaveable, state hoisting, and connecting to ViewModel for production apps.
This is Part 2 of a 10-part series on Jetpack Compose.
- Your First Screen
- State Management (this post)
- Navigation
- Lists
- Theming
- Forms
- Side Effects
- Custom Layouts
- API Integration
- Migration from XML
How Compose handles state
In the XML world, you update views imperatively: textView.text = "Hello". The view holds its own state. You mutate it.
In Compose, UI is a function of state. When state changes, Compose re-calls (recomposes) the affected functions and updates the UI. You don’t tell the UI what to change — you declare what it should look like for a given state.
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "Count: $count", style = MaterialTheme.typography.headlineMedium)
Button(onClick = { count++ }) {
Text("Increment")
}
}
}
Click the button → count changes → Compose recomposes Counter() → UI updates. No findViewById, no setText, no binding.
mutableStateOf
mutableStateOf creates an observable state holder. Compose tracks reads and triggers recomposition when the value changes:
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
var name by mutableStateOf("") // Compose observes this
Three ways to use it:
// 1. Property delegate (cleanest)
var count by mutableStateOf(0)
// Use: count++, Text("$count")
// 2. Destructuring
val (count, setCount) = mutableStateOf(0)
// Use: setCount(count + 1), Text("$count")
// 3. Direct state object
val countState = mutableStateOf(0)
// Use: countState.value++, Text("${countState.value}")
The property delegate (by) is the most common. Import getValue and setValue for it to work.
remember
Without remember, state resets on every recomposition:
@Composable
fun BrokenCounter() {
var count = mutableStateOf(0) // recreated every recomposition!
Button(onClick = { count.value++ }) {
Text("Count: ${count.value}") // always shows 0
}
}
remember preserves the value across recompositions:
@Composable
fun WorkingCounter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Count: $count")
}
}
remember says: “Create this value once and keep it across recompositions.” The block inside remember { } runs once, when the composable first enters the composition.
remember with keys
remember can take keys. When a key changes, the remembered value is recalculated:
@Composable
fun UserGreeting(userId: String) {
val greeting by remember(userId) {
mutableStateOf(computeGreeting(userId))
}
Text(greeting)
}
If userId changes, computeGreeting runs again. If it stays the same, the cached value is used.
rememberSaveable — Surviving configuration changes
remember survives recomposition but not configuration changes (screen rotation, process death). rememberSaveable does:
import androidx.compose.runtime.saveable.rememberSaveable
@Composable
fun SearchBar() {
var query by rememberSaveable { mutableStateOf("") }
TextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Search...") }
)
}
After rotation, the search query is preserved. rememberSaveable uses the saved instance state bundle under the hood.
It works automatically for primitive types and Parcelable. For custom types, provide a Saver:
data class Filter(val category: String, val sortBy: String)
val FilterSaver = run {
val categoryKey = "category"
val sortByKey = "sortBy"
mapSaver(
save = { mapOf(categoryKey to it.category, sortByKey to it.sortBy) },
restore = { Filter(it[categoryKey] as String, it[sortByKey] as String) }
)
}
@Composable
fun FilterScreen() {
var filter by rememberSaveable(stateSaver = FilterSaver) {
mutableStateOf(Filter("all", "date"))
}
}
State hoisting
State hoisting is the pattern of moving state up from a child composable to its parent. The child becomes stateless — it receives state and emits events:
Before hoisting (stateful)
@Composable
fun NameInput() {
var name by remember { mutableStateOf("") }
TextField(
value = name,
onValueChange = { name = it }
)
}
This component manages its own state. The parent can’t read the name, can’t pre-fill it, can’t validate it.
After hoisting (stateless)
@Composable
fun NameInput(
name: String,
onNameChange: (String) -> Unit
) {
TextField(
value = name,
onValueChange = onNameChange
)
}
Now the parent controls the state:
@Composable
fun RegistrationForm() {
var name by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
Column {
NameInput(name = name, onNameChange = { name = it })
EmailInput(email = email, onEmailChange = { email = it })
Button(
onClick = { submitRegistration(name, email) },
enabled = name.isNotBlank() && email.contains("@")
) {
Text("Register")
}
}
}
The parent has access to all the state. It can validate, combine, and control the children.
Rule of thumb
Hoist state to the lowest common ancestor that needs it. If only one screen uses the state, hoist to that screen’s composable. If multiple screens need it, hoist to a shared ViewModel.
ViewModel — State that survives navigation
For real apps, remember isn’t enough. You need state that:
- Survives configuration changes (rotation)
- Survives navigation (back stack)
- Is shared between composables on the same screen
- Connects to repositories and use cases
That’s what ViewModel is for:
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class TodoListState(
val items: List<TodoItem> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
class TodoViewModel(
private val repository: TodoRepository
) : ViewModel() {
private val _state = MutableStateFlow(TodoListState())
val state: StateFlow<TodoListState> = _state.asStateFlow()
init {
loadTodos()
}
fun loadTodos() {
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true)
try {
val items = repository.getAll()
_state.value = TodoListState(items = items)
} catch (e: Exception) {
_state.value = TodoListState(error = e.message)
}
}
}
fun addTodo(text: String) {
viewModelScope.launch {
val newItem = repository.add(text)
_state.value = _state.value.copy(
items = _state.value.items + newItem
)
}
}
fun toggleTodo(id: String) {
viewModelScope.launch {
repository.toggle(id)
loadTodos()
}
}
}
Connecting ViewModel to Compose
import androidx.compose.runtime.collectAsState
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun TodoScreen(viewModel: TodoViewModel = viewModel()) {
val state by viewModel.state.collectAsState()
when {
state.isLoading -> LoadingIndicator()
state.error != null -> ErrorMessage(state.error!!)
else -> TodoList(
items = state.items,
onToggle = { viewModel.toggleTodo(it) },
onAdd = { viewModel.addTodo(it) }
)
}
}
collectAsState() converts a StateFlow to Compose state. When the flow emits, the composable recomposes.
The state pattern
UI Event → ViewModel → Update StateFlow → Compose recomposes → UI updates
- User taps a button (UI event)
- Composable calls
viewModel.addTodo(text)(event handler) - ViewModel updates
_state.value(state change) - StateFlow emits new value (observation)
collectAsState()triggers recomposition (UI update)
This is a unidirectional data flow. Events flow up, state flows down.
Derived state
Sometimes state depends on other state. Use derivedStateOf:
@Composable
fun FilteredList(items: List<Item>) {
var query by remember { mutableStateOf("") }
val filteredItems by remember(items) {
derivedStateOf {
if (query.isBlank()) items
else items.filter { it.name.contains(query, ignoreCase = true) }
}
}
Column {
TextField(value = query, onValueChange = { query = it })
LazyColumn {
items(filteredItems) { item ->
Text(item.name)
}
}
}
}
derivedStateOf only recomputes when the state it reads actually changes. If query is “abc” and you type another “c”, the filter runs again. But if something else recomposes this composable without changing query or items, the filtered list is cached.
When to use what
| Situation | Tool |
|---|---|
| Simple local UI state (toggle, counter) | remember { mutableStateOf() } |
| State that survives rotation | rememberSaveable |
| State from a data source (API, DB) | ViewModel + StateFlow + collectAsState() |
| Computed state from other state | derivedStateOf |
| State shared across screens | ViewModel scoped to navigation graph |
Common mistakes
1. Forgetting remember
// Bug: resets every recomposition
var expanded = mutableStateOf(false)
// Fix
var expanded by remember { mutableStateOf(false) }
2. Creating ViewModel in composable
// Bug: creates new ViewModel every recomposition
val viewModel = TodoViewModel(repository)
// Fix: use viewModel() to scope it properly
val viewModel: TodoViewModel = viewModel()
3. Collecting flow without lifecycle awareness
// Dangerous: collects even when app is in background
val state = viewModel.state.collectAsState()
// Better: lifecycle-aware (stops when not visible)
val state by viewModel.state.collectAsStateWithLifecycle()
Use collectAsStateWithLifecycle() from lifecycle-runtime-compose for production apps.
Summary
mutableStateOf— observable state that triggers recompositionremember— preserves state across recompositionsrememberSaveable— preserves state across configuration changes- State hoisting — move state up, pass state down, emit events up
- ViewModel — state that survives navigation, connects to data layer
- Unidirectional data flow — events up, state down
Get these patterns right and most Compose state management is straightforward. Next: Navigation with type-safe routes.