State in Jetpack Compose
In Compose, state is any value that, when it changes, causes the UI to redraw. Compose's snapshot system tracks which composables read which state objects and schedules only those composables for recomposition.
remember and mutableStateOf
@Composable
fun Counter() {
// remember: survives recomposition, lost on navigation/recreation
var count by remember { mutableStateOf(0) }
Column {
Text("Count: $count")
Button(onClick = { count++ }) {
Text("Increment")
}
}
}
remember stores the value across recompositions of the same composable instance. When the composable leaves the composition (navigates away), the value is discarded. For persistence across navigation or process death, use rememberSaveable or hoist to a ViewModel.
State<T> and Recomposition
mutableStateOf returns a MutableState<T> — a state holder that Compose's snapshot system observes:
// Explicit State<T> type
val expanded: MutableState<Boolean> = remember { mutableStateOf(false) }
Text(if (expanded.value) "Collapse" else "Expand")
// Property delegation — most common style
var expanded by remember { mutableStateOf(false) }
Text(if (expanded) "Collapse" else "Expand")
// Destructuring — useful for passing read/write separately
val (isChecked, setChecked) = remember { mutableStateOf(false) }
Checkbox(checked = isChecked, onCheckedChange = setChecked)
Only composables that read a state object recompose when it changes. A composable that only passes state down without reading it does not recompose.
derivedStateOf — Avoid Redundant Recomposition
Use derivedStateOf when one piece of state is computed from another, and you want to avoid triggering recomposition every time the source changes (only trigger when the derived value actually changes).
@Composable
fun CartScreen(items: List<CartItem>) {
val listState = rememberLazyListState()
// BAD: recomposes every scroll event
val showScrollToTop = listState.firstVisibleItemIndex > 0
// GOOD: only recomposes when the boolean result flips
val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
Box {
LazyColumn(state = listState) {
items(items) { item -> CartItemRow(item) }
}
if (showScrollToTop) {
ScrollToTopButton(onClick = { /* scroll */ })
}
}
}
Another example — expensive filtering:
@Composable
fun ProductList(allProducts: List<Product>) {
var searchQuery by remember { mutableStateOf("") }
// derivedStateOf: filter only recalculates when query changes,
// not on every character typed if the result is the same
val filtered by remember {
derivedStateOf {
if (searchQuery.isBlank()) allProducts
else allProducts.filter { it.name.contains(searchQuery, ignoreCase = true) }
}
}
// ...
}
Side Effects in Compose
A side effect is any operation that escapes the scope of a composable — launching a coroutine, calling analytics, subscribing to a Flow, registering a listener. Because composables can recompose many times, side effects must be handled carefully.
The cardinal rule: never launch effects directly in the composable body.
// WRONG — runs on every recomposition
@Composable
fun BadExample() {
analytics.logScreenView("home") // called repeatedly!
LazyColumn { /* ... */ }
}
LaunchedEffect — Keyed Coroutine
LaunchedEffect launches a coroutine that is tied to the composable's lifecycle. When the composable leaves the composition, the coroutine is cancelled. When the key changes, the old coroutine is cancelled and a new one starts.
@Composable
fun SearchResults(query: String, viewModel: SearchViewModel = hiltViewModel()) {
// Restarts when query changes, cancelled when composable leaves
LaunchedEffect(query) {
viewModel.search(query)
}
// ...
}
// One-time effect on enter — use Unit or true as key
@Composable
fun HomeScreen(viewModel: HomeViewModel = hiltViewModel()) {
LaunchedEffect(Unit) {
viewModel.loadInitialData()
analytics.logScreenView("home")
}
// ...
}
// Multiple keys — restarts when either changes
@Composable
fun PaginatedList(category: String, sortOrder: SortOrder) {
LaunchedEffect(category, sortOrder) {
viewModel.load(category, sortOrder)
}
}
DisposableEffect — Lifecycle + Cleanup
DisposableEffect is for effects that need cleanup when the composable leaves the composition or the key changes. The onDispose block runs before the effect restarts (on key change) and when the composable leaves.
@Composable
fun LocationTracker(onLocationUpdate: (Location) -> Unit) {
val context = LocalContext.current
DisposableEffect(Unit) {
val locationManager = context.getSystemService(LocationManager::class.java)
val listener = LocationListener { location ->
onLocationUpdate(location)
}
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER, 1000L, 1f, listener
)
onDispose {
// Cleanup when composable leaves composition
locationManager.removeUpdates(listener)
}
}
}
// Lifecycle-aware side effect
@Composable
fun LifecycleScreen(onResume: () -> Unit) {
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) onResume()
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
SideEffect — Sync with Non-Compose Code
SideEffect runs on every successful recomposition. Use it to publish Compose state to non-Compose code (like analytics libraries or non-Compose UI components).
@Composable
fun AnalyticsTrackedScreen(screenName: String, userId: String) {
// Runs after every successful recomposition
SideEffect {
analytics.setUserProperty("user_id", userId)
analytics.setCurrentScreen(screenName)
}
// ...
}
// Updating a non-Compose controller
@Composable
fun MapView(mapController: GoogleMapController, userLocation: LatLng) {
SideEffect {
mapController.animateCamera(userLocation)
}
}
Unlike LaunchedEffect, SideEffect is not a coroutine — it's synchronous and has no key. It always runs after every recomposition.
produceState — Convert Callbacks to State
produceState converts external (non-Compose) subscription sources into State<T>. It launches a coroutine in the composition scope and exposes a value you can update.
@Composable
fun NetworkStatus(): State<Boolean> {
val context = LocalContext.current
return produceState(initialValue = true) {
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { value = true }
override fun onLost(network: Network) { value = false }
}
connectivityManager.registerDefaultNetworkCallback(callback)
awaitDispose {
connectivityManager.unregisterNetworkCallback(callback)
}
}
}
// Usage
@Composable
fun ConnectivityBanner() {
val isOnline by NetworkStatus()
if (!isOnline) {
OfflineBanner()
}
}
Another common use — converting a suspend function to State:
@Composable
fun UserAvatar(userId: String, userRepository: UserRepository): State<User?> {
return produceState<User?>(initialValue = null, userId) {
value = userRepository.getUser(userId)
}
}
collectAsStateWithLifecycle — Safe Flow Collection
collectAsStateWithLifecycle collects a Flow and represents its latest value as State<T>, automatically pausing collection when the lifecycle is below STARTED (screen not visible).
// Dependency: androidx.lifecycle:lifecycle-runtime-compose
@Composable
fun ProductScreen(viewModel: ProductViewModel = hiltViewModel()) {
// Pauses when app is in background — does NOT collect during backgrounding
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// vs collectAsState — keeps collecting even when screen is in background
// val uiState by viewModel.uiState.collectAsState()
ProductContent(uiState)
}
Always prefer collectAsStateWithLifecycle over collectAsState for ViewModel StateFlows. It respects Android's lifecycle and avoids unnecessary work in the background.
rememberCoroutineScope
rememberCoroutineScope gives you a CoroutineScope tied to the composable's lifecycle. Use it for launching coroutines in response to user events (where LaunchedEffect is not appropriate).
@Composable
fun SubmitButton(onSubmit: suspend () -> Unit) {
val scope = rememberCoroutineScope()
var isSubmitting by remember { mutableStateOf(false) }
Button(
onClick = {
scope.launch {
isSubmitting = true
try {
onSubmit()
} finally {
isSubmitting = false
}
}
},
enabled = !isSubmitting
) {
if (isSubmitting) CircularProgressIndicator(modifier = Modifier.size(16.dp))
else Text("Submit")
}
}
LaunchedEffect is for automatic effects triggered by state changes. rememberCoroutineScope is for effects triggered by user interaction.
When to Use Each API
| API | Use When |
|---|---|
remember | Store state that survives recomposition |
rememberSaveable | Survive recomposition AND process death |
derivedStateOf | Expensive computation derived from state; avoid unnecessary recomposition |
LaunchedEffect(key) | Run a coroutine when the composable enters; restart when key changes |
DisposableEffect(key) | Register/unregister a listener; need cleanup on leave or key change |
SideEffect | Sync Compose state to non-Compose code on every recomposition |
produceState | Bridge a callback/subscription API to State<T> |
collectAsStateWithLifecycle | Collect ViewModel StateFlow/SharedFlow respecting lifecycle |
rememberCoroutineScope | Launch coroutines from event handlers (button clicks) |
Common Mistakes
Mistake 1: Running effects in the composable body
// WRONG
@Composable
fun HomeScreen() {
viewModel.loadData() // called on EVERY recomposition
}
// CORRECT
@Composable
fun HomeScreen() {
LaunchedEffect(Unit) {
viewModel.loadData() // called once when composable enters
}
}
Mistake 2: Wrong key for LaunchedEffect
// BUG: uses Unit as key, but should restart when userId changes
@Composable
fun UserProfile(userId: String) {
LaunchedEffect(Unit) { // WRONG key — never restarts
viewModel.loadUser(userId)
}
}
// CORRECT: restarts when userId changes
@Composable
fun UserProfile(userId: String) {
LaunchedEffect(userId) {
viewModel.loadUser(userId)
}
}
Mistake 3: Forgetting cleanup in DisposableEffect
// BUG: listener leaks when composable leaves
DisposableEffect(Unit) {
eventBus.register(listener)
onDispose { } // forgot to unregister!
}
// CORRECT
DisposableEffect(Unit) {
eventBus.register(listener)
onDispose { eventBus.unregister(listener) }
}
Mistake 4: Using derivedStateOf without remember
// BUG: creates a new derivedState on every recomposition — defeats the purpose
val filtered = derivedStateOf { items.filter { it.visible } }
// CORRECT
val filtered by remember { derivedStateOf { items.filter { it.visible } } }
Key Takeaways
| Concept | Rule |
|---|---|
| State reads trigger recomposition | Only composables that read state recompose |
derivedStateOf | Wrap in remember; reduces recomposition for computed values |
LaunchedEffect | Keyed coroutine; cancels on key change or composition leave |
DisposableEffect | Always implement onDispose for cleanup |
SideEffect | Sync state to non-Compose code; no coroutine, no cleanup |
produceState | Bridge callback APIs to State |
collectAsStateWithLifecycle | Prefer over collectAsState for lifecycle awareness |
| Body side effects | Never — always wrap in the appropriate effect API |