Why State Management Matters in Compose
State in Jetpack Compose for Android & Kotlin developers is fundamental to building responsive, predictable UIs. Without proper state management, your composables become fragile—UI state gets lost on recomposition, configuration changes wipe out user input, and components can't be reused reliably. We're going to walk through the practical mechanics of state in Compose and show you how to structure state so your apps remain maintainable as they grow.
Understanding remember — Keep State Across Recompositions
Compose recomposes your UI constantly. Every time a state change, event, or animation tick occurs, your composable functions run again from scratch. Without intervention, local variables would be recreated on every pass, losing their values entirely. The remember function solves this: it stores a value in Compose's memory and returns the same instance on subsequent recompositions.
Think of remember as a cache tied to your composable's location in the tree. Compose tracks which composables produce which remembered values, so when your function runs again, it retrieves the cached value instead of creating a new one.
@Composable
fun CounterExample() {
var count by remember { mutableStateOf(0) }
Column(modifier = Modifier.padding(16.dp)) {
Text("Count: $count", style = MaterialTheme.typography.headlineSmall)
Button(onClick = { count++ }) {
Text("Increment")
}
}
}Here, count is remembered across every recomposition. Without remember, a new MutableState would be created each time, and your UI would never update. With it, the state persists and changes propagate to the UI automatically.
A crucial detail: remember is scoped to the composable's position in the composition tree. If you remove a composable from the tree and add it back elsewhere, its remembered state is discarded. This behavior is usually what you want, but it's worth understanding.
mutableStateOf — Changes Trigger Recomposition
mutableStateOf creates a MutableState<T> object that holds a value and notifies Compose when that value changes. When you modify the state, Compose schedules a recomposition of any composables that read that state.
In practice, you rarely interact with MutableState directly. The by keyword (via Kotlin's delegated properties) lets you read and write the underlying value as if it were a simple variable. This is syntactic sugar that makes your code cleaner:
// With delegation (clean and idiomatic)
var count by remember { mutableStateOf(0) }
count++ // Calls state.value++, triggers recomposition
// Without delegation (verbose, rarely used)
val countState = remember { mutableStateOf(0) }
countState.value++ // Explicit, harder to readWhen you assign a new value to a mutableStateOf variable, Compose detects the change and reruns your composable. Only composables that actually read the changed state are recomposed—Compose's smart recomposition system skips functions that don't depend on it.
This is also where performance can degrade if you're not careful. Every read of a state variable inside a composable registers a dependency. If you hoist state too high or read it in too many places, you'll trigger unnecessary recompositions. We'll address this pattern when we discuss state hoisting.
rememberSaveable — Survive Configuration Changes
When the device rotates, the system destroys and recreates the entire activity. In traditional Android, you'd use savedInstanceState bundles to preserve UI state. In Compose, rememberSaveable does this automatically by integrating with the platform's bundle mechanism.
rememberSaveable works like remember, but it also persists state across configuration changes. The system serializes your data, saves it, and restores it when the activity is recreated.
@Composable
fun FormScreen() {
var username by rememberSaveable { mutableStateOf("") }
var email by rememberSaveable { mutableStateOf("") }
Column(modifier = Modifier.padding(16.dp)) {
TextField(
value = username,
onValueChange = { username = it },
label = { Text("Username") }
)
TextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") }
)
Button(onClick = { submitForm(username, email) }) {
Text("Submit")
}
}
}Rotate your device, and username and email retain their values. The form fields are exactly as the user left them.
By default, rememberSaveable uses the bundle-based mechanism, which works for simple types: strings, integers, floats, booleans, and collections of these types. For custom objects, you need to provide a Saver:
data class User(val id: Int, val name: String)
@Composable
fun UserScreen() {
val userSaver = run {
val idKey = "id"
val nameKey = "name"
mapSaver(
save = { mapOf(idKey to it.id, nameKey to it.name) },
restore = { User(it[idKey] as Int, it[nameKey] as String) }
)
}
var user by rememberSaveable(saver = userSaver) {
mutableStateOf(User(1, "Alice"))
}
Text("User: ${user.name}")
}For most apps, you won't need custom savers often. Stick to simple types in UI state, and move complex objects to ViewModels (which survive configuration changes automatically).
State Hoisting — Pass State Down and Events Up
State hoisting is a design pattern where you move state from a composable up to its parent. This makes composables reusable, testable, and decoupled from specific state implementations. Instead of a composable managing its own state, it receives the current state as a parameter and calls a callback when the user interacts with it.
Consider a reusable button component. Without hoisting, the button might manage its own click count. But that's not scalable—you can't reuse it in different contexts where it needs to behave differently. With hoisting, the button is stateless and accepts the current count and an event callback:
// Stateless, reusable button component
@Composable
fun CounterButton(
count: Int,
onCountChange: (Int) -> Unit,
modifier: Modifier = Modifier
) {
Button(
onClick = { onCountChange(count + 1) },
modifier = modifier
) {
Text("Clicked $count times")
}
}
// Parent composable manages state
@Composable
fun CounterScreen() {
var count by remember { mutableStateOf(0) }
Column(modifier = Modifier.padding(16.dp)) {
CounterButton(
count = count,
onCountChange = { newCount -> count = newCount }
)
Text("Total: $count")
}
}The CounterButton doesn't know or care about state. It's a pure function: given a count and a callback, it renders and delegates events. You can use it anywhere, test it easily, and compose it into larger screens without coupling.
This pattern scales beautifully. A complex form might hoist state to a screen-level composable, which reads from a ViewModel. The ViewModel manages business logic and persistence. The composables remain thin and focused on rendering.
// Stateless form fields
@Composable
fun LoginForm(
email: String,
password: String,
onEmailChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onLoginClick: () -> Unit
) {
Column(modifier = Modifier.padding(16.dp)) {
TextField(
value = email,
onValueChange = onEmailChange,
label = { Text("Email") }
)
TextField(
value = password,
onValueChange = onPasswordChange,
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation()
)
Button(onClick = onLoginClick) {
Text("Log In")
}
}
}
// Screen hoists state and connects to ViewModel
@Composable
fun LoginScreen(viewModel: LoginViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsState()
LoginForm(
email = uiState.email,
password = uiState.password,
onEmailChange = { viewModel.updateEmail(it) },
onPasswordChange = { viewModel.updatePassword(it) },
onLoginClick = { viewModel.login() }
)
}This structure gives you flexibility: you can test LoginForm in isolation by passing different parameters, you can test LoginScreen by mocking the ViewModel, and your ViewModel remains independent of Compose details.
Common Mistakes
Creating State Without remember
This is a classic pitfall. A new state object is created on every recomposition, so changes never persist:
// Wrong
@Composable
fun BadCounter() {
var count = mutableStateOf(0) // New state every recomposition!
Button(onClick = { count.value++ }) {
Text("Count: ${count.value}")
}
}
// Correct
@Composable
fun GoodCounter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Count: $count")
}
}Hoisting State Too High or Too Low
Hoist state to the lowest common ancestor that needs it. If you hoist to the screen level when only one child composable needs it, you're adding unnecessary parameters and recompositions. Conversely, if two siblings both need the same state, hoist it to their parent, not leave it in each child.
Using State in Loops or Conditionals
Compose tracks remembered values by call order. If you create remembered state inside a conditional or loop, the order changes based on runtime conditions, breaking the identity of remembered objects:
// Wrong
@Composable
fun BadList(items: List<String>) {
items.forEach { item ->
var isExpanded by remember { mutableStateOf(false) } // Order varies!
// ...
}
}
// Correct: State keyed by item
@Composable
fun GoodList(items: List<String>) {
items.forEach { item ->
var isExpanded by remember(item) { mutableStateOf(false) }
// ...
}
}The remember(item) call includes item as a key. If the key changes, Compose discards the old remembered value and creates a new one.
Forgetting to Use rememberSaveable in Persistent UIs
If your form or search screen uses remember instead of rememberSaveable, a device rotation will wipe out the user's input. This is a common source of frustration for end users and a red flag in code review.
Key Takeaways
- Use
remember { mutableStateOf(value) }to keep state across recompositions within a composable's lifetime. mutableStateOfchanges trigger recomposition only for composables that read the changed state; Compose's smart recomposition skips the rest.- Replace
rememberwithrememberSaveablefor user input and any state that should survive configuration changes like device rotation. - Hoist state to the parent composable to make child composables reusable, testable, and decoupled from state management.
- Always wrap mutable state in
remember; forgetting this is the #1 cause of state bugs in Compose. - Use
remember(key1, key2) { ... }to refresh remembered values when dependencies change. - For complex state and business logic, hoist beyond composables to ViewModels and store services; keep composables lean and presentational.






Comments
Loading comments…