State management is central to Jetpack Compose. This guide covers the tools and patterns from local state to ViewModel integration.
remember and mutableStateOf — local state
Best for: UI-only state that doesn’t need to survive configuration changes.
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Count: $count")
Button(onClick = { count++ }) {
Text("Increment")
}
}
}rememberkeeps the value across recompositionsmutableStateOfcreates an observable state holder- Use
bydelegate for cleaner syntax (requiresimport androidx.compose.runtime.getValueandsetValue)
Other state holders
// MutableStateList — observable list
val items = remember { mutableStateListOf("Apple", "Banana") }
items.add("Cherry")
// MutableStateMap — observable map
val scores = remember { mutableStateMapOf("Alice" to 95) }
scores["Bob"] = 87
// rememberSaveable — survives configuration changes
var text by rememberSaveable { mutableStateOf("") }StateFlow and ViewModel — app state
Best for: business logic, state that survives configuration changes, and state shared across screens.
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.collectAsStateWithLifecycle()
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Count: $count")
Button(onClick = { viewModel.increment() }) {
Text("Increment")
}
}
}Use collectAsStateWithLifecycle() (from androidx.lifecycle:lifecycle-runtime-compose) instead of collectAsState() to respect the lifecycle — it stops collecting when the app goes to the background.
State hoisting
Move state to the caller so the composable becomes stateless and reusable:
// Before — stateful (hard to test, hard to reuse)
@Composable
fun SearchBar() {
var query by remember { mutableStateOf("") }
TextField(
value = query,
onValueChange = { query = it },
label = { Text("Search") }
)
}
// After — stateless (hoisted)
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
TextField(
value = query,
onValueChange = onQueryChange,
label = { Text("Search") },
modifier = modifier
)
}
// The caller manages the state
@Composable
fun SearchScreen() {
var query by rememberSaveable { mutableStateOf("") }
SearchBar(query = query, onQueryChange = { query = it })
}Side effects
For operations that need to happen outside of composition:
// LaunchedEffect — run a suspend function when a key changes
@Composable
fun UserProfile(userId: String, viewModel: UserViewModel) {
LaunchedEffect(userId) {
viewModel.loadUser(userId)
}
val user by viewModel.user.collectAsStateWithLifecycle()
if (user != null) {
Text(user!!.name)
}
}
// DisposableEffect — register/unregister callbacks
@Composable
fun NetworkMonitor(networkCallback: NetworkCallback) {
DisposableEffect(Unit) {
val manager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
manager.registerNetworkCallback(networkRequest, networkCallback)
onDispose {
manager.unregisterNetworkCallback(networkCallback)
}
}
}
// rememberCoroutineScope — launch coroutines from event handlers
@Composable
fun SaveButton(viewModel: SaveViewModel) {
val scope = rememberCoroutineScope()
Button(onClick = {
scope.launch { viewModel.save() }
}) {
Text("Save")
}
}
// SideEffect — publish state to non-Compose code after every recomposition
@Composable
fun AnalyticsTracker(screenName: String) {
SideEffect {
analytics.trackScreenView(screenName)
}
}Which state approach to use?
| Pattern | Best for | Survives config change |
|---|---|---|
remember + mutableStateOf |
Local UI state, simple toggles | No |
rememberSaveable |
Local state that survives rotation | Yes |
MutableStateList/Map |
Observable collections | No |
ViewModel + StateFlow |
Business logic, shared state | Yes |
| State hoisting | Reusable, testable components | Depends on caller |