PCSalt
YouTube GitHub
Back to Jetpack Compose
Jetpack Compose · 3 min read

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.

  1. Your First Screen
  2. State Management (this post)
  3. Navigation
  4. Lists
  5. Theming
  6. Forms
  7. Side Effects
  8. Custom Layouts
  9. API Integration
  10. 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
  1. User taps a button (UI event)
  2. Composable calls viewModel.addTodo(text) (event handler)
  3. ViewModel updates _state.value (state change)
  4. StateFlow emits new value (observation)
  5. 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

SituationTool
Simple local UI state (toggle, counter)remember { mutableStateOf() }
State that survives rotationrememberSaveable
State from a data source (API, DB)ViewModel + StateFlow + collectAsState()
Computed state from other statederivedStateOf
State shared across screensViewModel 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 recomposition
  • remember — preserves state across recompositions
  • rememberSaveable — 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.