State Management in Jetpack Compose: remember, mutableStateOf, and Beyond
Source: Dev.to
Understanding Composition and Recomposition
Before diving into state management, it’s important to understand how Compose works. When you write a composable function, it is executed as part of Compose’s composition process. As state changes, Compose recomposes—it re‑executes composable functions to update the UI.
Key challenge: values created locally in a composable function are recreated on every recomposition. This is where state‑management tools come in.
The Basics: remember and mutableStateOf
remember: Preserving Values Across Recompositions
remember is the foundation of state management in Compose. It allows you to preserve a value across recompositions:
@Composable
fun CounterExample() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Clicked $count times")
}
}
In this example, count is retained across recompositions. When the button is clicked, the state updates, triggering a recomposition that reflects the new value.
mutableStateOf: Creating Observable State
mutableStateOf creates a state object that Compose observes. When the state changes, any composables that read that state are recomposed:
@Composable
fun LoginForm() {
val emailState = remember { mutableStateOf("") }
val passwordState = remember { mutableStateOf("") }
Column {
TextField(
value = emailState.value,
onValueChange = { emailState.value = it },
label = { Text("Email") }
)
TextField(
value = passwordState.value,
onValueChange = { passwordState.value = it },
label = { Text("Password") }
)
}
}
Using the delegation syntax (var … by) is more concise and idiomatic:
@Composable
fun LoginForm() {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
Column {
TextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") }
)
TextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") }
)
}
}
rememberSaveable: Preserving State Through Configuration Changes
remember preserves state across recompositions, but it does not survive configuration changes (e.g., screen rotation). For persistence across such changes, use rememberSaveable:
@Composable
fun PersistentCounterExample() {
var count by rememberSaveable { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Clicked $count times (survives rotation)")
}
}
rememberSaveable leverages the Bundle mechanism to save and restore state, similar to traditional Android development.
derivedStateOf: Computing Values from State
Sometimes you need to compute a value based on state changes, but you don’t want recomposition to happen for every change. derivedStateOf creates a derived state that only notifies observers when its value actually changes:
@Composable
fun TextSearchExample() {
var searchQuery by remember { mutableStateOf("") }
// This expensive computation only runs when searchQuery actually changes
val searchResults by remember(searchQuery) {
derivedStateOf { performExpensiveSearch(searchQuery) }
}
Column {
TextField(
value = searchQuery,
onValueChange = { searchQuery = it },
label = { Text("Search") }
)
LazyColumn {
items(searchResults.size) { index ->
Text(searchResults[index])
}
}
}
}
Even if searchQuery is updated rapidly, the expensive search operation runs only when the derived value would actually change.
State Hoisting: Lifting State Up
State hoisting is a design pattern where you move state to a common parent composable. This makes the state shareable between multiple child composables and easier to test:
@Composable
fun ParentComponent() {
var sharedState by remember { mutableStateOf("") }
Column {
ChildComponentA(
state = sharedState,
onStateChange = { sharedState = it }
)
ChildComponentB(state = sharedState)
}
}
@Composable
fun ChildComponentA(
state: String,
onStateChange: (String) -> Unit
) {
TextField(
value = state,
onValueChange = onStateChange
)
}
@Composable
fun ChildComponentB(state: String) {
Text("Shared state: $state")
}
State hoisting makes your composables more reusable and easier to test because their behavior depends only on parameters, not internal state.
ViewModel vs. Local State: When to Use Each
- Local state (
remember,mutableStateOf) – Ideal for UI‑only state that does not need to survive process death or be shared across multiple screens. ViewModel‑backed state – Use when the state must survive configuration changes, be shared across composables in different parts of the UI, or be persisted beyond the UI lifecycle (e.g., data fetched from a repository).
class CounterViewModel : ViewModel() {
private val _count = mutableStateOf(0)
val count: State = _count
fun increment() { _count.value++ }
}
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
val count by viewModel.count
Button(onClick = { viewModel.increment() }) {
Text("Clicked $count times")
}
}
In this example, the count survives configuration changes because it lives in a ViewModel.
Summary
remember+mutableStateOf→ UI‑local, recomposition‑aware state.rememberSaveable→ UI‑local state that survives configuration changes.derivedStateOf→ Efficient derived values that avoid unnecessary recompositions.- State hoisting → Improves reusability and testability.
ViewModel→ Handles shared, long‑lived, or process‑surviving state.
Understanding and applying these tools will help you build robust, maintainable, and performant Compose applications. Happy composing!
Use remember for UI‑related State That Doesn’t Need to Survive Process Death
Typical use‑cases
- Toggle visibility of UI elements
- Text field input while editing
- Scroll position
Temporary UI state
@Composable
fun ToggleVisibility() {
var isVisible by remember { mutableStateOf(true) }
Button(onClick = { isVisible = !isVisible }) {
Text(if (isVisible) "Hide" else "Show")
}
if (isVisible) {
Text("Content is visible")
}
}
ViewModel for Business Logic and Data That Should Survive Process Death
Typical use‑cases
- User data from a database or API
- Application‑wide state
- Business logic
State that persists across the app lifecycle
class UserViewModel : ViewModel() {
private val _userState = MutableStateFlow<User?>(null)
val userState: StateFlow<User?> = _userState.asStateFlow()
init {
loadUser()
}
private fun loadUser() {
viewModelScope.launch {
_userState.value = userRepository.getUser()
}
}
}
@Composable
fun UserScreen(viewModel: UserViewModel = hiltViewModel()) {
val user by viewModel.userState.collectAsState()
user?.let {
Text("Welcome, ${it.name}")
}
}
Combining ViewModel with Local State
The most robust pattern mixes both approaches:
class ProductViewModel : ViewModel() {
private val _products = MutableStateFlow<List<Product>>(emptyList())
val products: StateFlow<List<Product>> = _products.asStateFlow()
init {
loadProducts()
}
private fun loadProducts() {
viewModelScope.launch {
_products.value = productRepository.getProducts()
}
}
}
@Composable
fun ProductListScreen(viewModel: ProductViewModel = hiltViewModel()) {
val products by viewModel.products.collectAsState()
var selectedProductId by remember { mutableStateOf<String?>(null) }
Row {
LazyColumn(modifier = Modifier.weight(1f)) {
items(products) { product ->
ProductItem(
product = product,
isSelected = selectedProductId == product.id,
onSelect = { selectedProductId = product.id }
)
}
}
selectedProductId?.let { id ->
ProductDetails(
product = products.find { it.id == id },
modifier = Modifier.weight(1f)
)
}
}
}
Best Practices for State Management
- Hoist state as high as needed – Move state to the lowest common parent of composables that need it.
- Keep state close to where it’s used – Don’t hoist higher than necessary; this preserves reusability.
- Use
ViewModelfor persistent state – Data that must survive process death belongs in aViewModel. - Avoid mutable shared state – Prefer immutable data structures and a unidirectional data flow.
- Test composables with state hoisting – Hoisted state makes composables easier to test because you can inject test values.
- Use
rememberSaveablefor UI state – When UI state must survive configuration changes, wrap it withrememberSaveable.
Conclusion
State management in Jetpack Compose becomes straightforward once you understand the core tools:
remember/mutableStateOf→ local, temporary UI staterememberSaveable→ UI state that survives configuration changesViewModel→ persistent business logic and data
Choose the right tool for each situation: use local state for transient UI concerns, a ViewModel for data that must outlive the UI, and hoist state to share it efficiently between composables.
All 8 templates demonstrate proper state management.
https://myougatheax.gumroad.com