androidengineers.Book a session

State Management

Compose State & Side-Effects Overview

article25 minHard

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

APIUse When
rememberStore state that survives recomposition
rememberSaveableSurvive recomposition AND process death
derivedStateOfExpensive 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
SideEffectSync Compose state to non-Compose code on every recomposition
produceStateBridge a callback/subscription API to State<T>
collectAsStateWithLifecycleCollect ViewModel StateFlow/SharedFlow respecting lifecycle
rememberCoroutineScopeLaunch 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

ConceptRule
State reads trigger recompositionOnly composables that read state recompose
derivedStateOfWrap in remember; reduces recomposition for computed values
LaunchedEffectKeyed coroutine; cancels on key change or composition leave
DisposableEffectAlways implement onDispose for cleanup
SideEffectSync state to non-Compose code; no coroutine, no cleanup
produceStateBridge callback APIs to State
collectAsStateWithLifecyclePrefer over collectAsState for lifecycle awareness
Body side effectsNever — always wrap in the appropriate effect API

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Compose State & Side-Effects Overview | Android System Design | Android Engineers