State is the heart of every Compose app. You have used remember, mutableStateOf, rememberSaveable and ViewModels. This lesson makes you fluent: where to put state, how to package it, and how to safely do things that are not pure UI (start a timer, load data, register a listener, show a snackbar). Those "things outside the UI" are side effects, and getting them wrong causes the bugs that are hardest to find: duplicated network calls, leaked listeners, stale values and screens that fight each other.
This lesson covers A Compose state of mind, Where to hoist your state, Advanced state and side effects and State holders and state production from Pathway 3 of the Jetpack Compose for Android developers programme (https://developer.android.com/courses/pathways/jetpack-compose-for-android-developers-3), and follows the ideas of the official Advanced State and Side Effects in Jetpack Compose codelab with our own examples.
By the end you will be able to:
- Decide where to hoist state: to the caller, a plain state holder class, or a ViewModel.
- Write a state holder class with
rememberand a customSaver. - Use
rememberSaveablewith parcelable,listSaverandmapSaver. - Explain the composable lifecycle: enter, recompose, leave.
- Use the effect APIs correctly:
LaunchedEffect,rememberCoroutineScope,rememberUpdatedState,DisposableEffect,SideEffect,produceState,derivedStateOfandsnapshotFlow. - Pick the right API with a decision tree.
- Avoid the classic effect bugs.
Big idea: composition must stay pure: given the same inputs it draws the same UI and does nothing else. Anything else belongs in an effect, with a clear start, stop and key.
- State, recomposition,
remember, state hoisting (lessons 17, 24 and 37). - ViewModel and
StateFlow(lessons 24–25). - Coroutines,
launch,delay, cancellation (lesson 28). - Flow (lesson 33).
- Lifecycle (lesson 23).
Three ideas, repeated in all of Compose:
- UI = f(state). The screen is a function of state. Change state, and Compose re-runs only the parts that read it.
- Composables may run at any time, in any order, many times, or be skipped. Never rely on a composable running exactly once, and never do work in its body that is not safe to repeat (write to a database, start a request, launch a coroutine, register a listener).
- State flows down, events flow up (unidirectional data flow).
The composable lifecycle
+-----------+ recomposition (0..N times) +-----------+
enter -> | composed | -----------------------------------> | leaves |
the tree +-----------+ | the tree |
+-----------+
effects: start here -> <- cancelled/disposed here
A composable enters the composition the first time it is called, may recompose any number of times, and leaves when it is no longer called or removed (for example when if (show) becomes false). remember keeps a value across recompositions, and forgets it when the composable leaves. Effects tie their work to this lifecycle: start on enter, restart when keys change, stop on leave.
Where does state live? (state hoisting, three levels)
| Level | Use it when | Example |
|---|---|---|
Inside the composable (remember) |
Pure UI state nobody else needs | Whether a dropdown is open |
| A plain state holder class (remembered) | Complex UI state and UI logic shared by several composables, no business logic | ScaffoldState, a form's field state, a calendar picker's selected range |
| A ViewModel | Business logic, data from other layers, state that must survive configuration change, screen-level state | The list of students, loading and error |
Rule of thumb for hoisting: hoist state to the lowest common ancestor that reads or writes it, no higher. Hoist UI element state (scroll position, expansion) as high as the composables that need it; hoist screen UI state to a ViewModel (lesson 61). The official guidance describes the same idea: UI behaviour logic and UI element state in plain state holders, business logic in the ViewModel.
We will build a registration form whose logic grows beyond a few remembers, and use a state holder to keep the composable simple.
The messy version
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@Composable
fun MessyForm() {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirm by remember { mutableStateOf("") }
val emailError = email.isNotEmpty() && !email.contains("@")
val passwordError = password.isNotEmpty() && password.length < 8
val confirmError = confirm.isNotEmpty() && confirm != password
// ...the validation grows with every field, inside the composable
OutlinedTextField(email, { email = it }, label = { Text("Email") }, isError = emailError)
// ...
}
The composable mixes UI with validation rules. It cannot be reused, and it is hard to test without Compose.
The state holder
package com.example.formdemo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
@Stable
class RegistrationState(
email: String = "",
password: String = "",
confirm: String = ""
) {
var email by mutableStateOf(email)
var password by mutableStateOf(password)
var confirm by mutableStateOf(confirm)
val emailError: Boolean get() = email.isNotEmpty() && !email.contains("@")
val passwordError: Boolean get() = password.isNotEmpty() && password.length < 8
val confirmError: Boolean get() = confirm.isNotEmpty() && confirm != password
val canSubmit: Boolean
get() = email.isNotEmpty() && !emailError &&
password.length >= 8 && confirm == password
companion object {
val Saver: Saver<RegistrationState, *> = listSaver(
save = { listOf(it.email, it.password, it.confirm) },
restore = { RegistrationState(it[0], it[1], it[2]) }
)
}
}
@Composable
fun rememberRegistrationState(): RegistrationState =
rememberSaveable(saver = RegistrationState.Saver) { RegistrationState() }
Points to notice:
- The class holds
mutableStateOfproperties, so any composable that readsstate.emailrecomposes when it changes. Properties likeemailErrorare derived from state, so they update automatically and never go stale. Saver/listSaverexplain how to turn the object into something theBundlecan store (a list of strings) and back. That makes it survive rotation and process death, asrememberSaveabledoes for simple types. (Saving a password is a design decision: you may prefer not to. Here we save it for the sake of the example; for real apps consider leaving passwords out of saved state.)- A
rememberXxxState()factory function is the standard Compose naming convention for state holders (rememberScrollState,rememberLazyListState). @Stableis a promise to the compiler that the class notifies Compose of changes (lesson 57 explains stability).
The composable now reads like UI
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun RegistrationForm(
onSubmit: (email: String, password: String) -> Unit,
modifier: Modifier = Modifier,
state: RegistrationState = rememberRegistrationState()
) {
Column(modifier.padding(16.dp)) {
OutlinedTextField(
value = state.email,
onValueChange = { state.email = it },
label = { Text("Email") },
isError = state.emailError
)
OutlinedTextField(
value = state.password,
onValueChange = { state.password = it },
label = { Text("Password (8+ characters)") },
isError = state.passwordError
)
OutlinedTextField(
value = state.confirm,
onValueChange = { state.confirm = it },
label = { Text("Confirm password") },
isError = state.confirmError
)
Button(
onClick = { onSubmit(state.email, state.password) },
enabled = state.canSubmit
) { Text("Register") }
}
}
Passing the state holder as a parameter with a default (state: RegistrationState = rememberRegistrationState()) lets callers supply their own (for tests, previews or hoisting higher), which is a common Compose API pattern.
Testing the logic without Compose
Because RegistrationState is plain Kotlin with Compose state, you can test its rules with a simple unit test:
package com.example.formdemo
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class RegistrationStateTest {
@Test
fun submitRequiresMatchingStrongPassword() {
val s = RegistrationState(email = "ada@example.ng", password = "12345678", confirm = "1234567")
assertFalse(s.canSubmit)
s.confirm = "12345678"
assertTrue(s.canSubmit)
}
}
(Snapshot state objects can be read outside composition in simple unit tests like this. For heavier use, wrap in Snapshot.withMutableSnapshot or use the Compose test rule.)
remember survives recomposition but not configuration change or process death. rememberSaveable stores the value in the saved instance state Bundle (lesson 23).
| Value type | How to save |
|---|---|
Primitives, String, Parcelable, Serializable, collections of those |
rememberSaveable { mutableStateOf(...) } works automatically |
| A custom class | @Parcelize (Kotlin Parcelize plugin), or Saver, listSaver, mapSaver |
Parcelize
import android.os.Parcelable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import kotlinx.parcelize.Parcelize
@Parcelize
data class City(val name: String, val country: String) : Parcelable
// inside a composable:
var city by rememberSaveable { mutableStateOf(City("Ibadan", "Nigeria")) }
This needs the kotlin-parcelize Gradle plugin (id("kotlin-parcelize")).
mapSaver
import androidx.compose.runtime.saveable.mapSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
data class Point(val x: Int, val y: Int)
val PointSaver = mapSaver(
save = { mapOf("x" to it.x, "y" to it.y) },
restore = { Point(it["x"] as Int, it["y"] as Int) }
)
// inside a composable:
var point by rememberSaveable(stateSaver = PointSaver) { mutableStateOf(Point(0, 0)) }
Use rememberSaveable(stateSaver = ...) when the saver is for the value inside a MutableState. Keep saved state small (a few kilobytes at most): the Bundle is limited and large data causes TransactionTooLargeException. Large or complex data belongs in a ViewModel plus a database (lessons 24 and 34).
A side effect is a change to the outside world that happens during composition: starting a coroutine, registering a callback, writing to a log or database, updating non-Compose state, showing a snackbar. Because composition can run repeatedly, these must be wrapped in effect APIs that give them a lifecycle.
The decision tree
Need to run a suspend function / coroutine?
|-- when the composable enters (and restart when a key changes) -> LaunchedEffect(key) { ... }
|-- from an event handler (a click, a callback) -> rememberCoroutineScope() + scope.launch
|-- to produce a State<T> from a non-Compose source (Flow, callback) -> produceState / collectAsState...
Need setup AND cleanup (register/unregister a listener, observer, receiver)?
-> DisposableEffect(key) { ...; onDispose { ... } }
Need to publish Compose state to non-Compose code on EVERY successful recomposition?
-> SideEffect { ... }
Need an effect to use the latest value of something WITHOUT restarting?
-> rememberUpdatedState(value)
Need a value computed from other state that changes less often?
-> derivedStateOf { ... }
Need to turn Compose state into a Flow (to debounce, filter)?
-> snapshotFlow { ... }
LaunchedEffect: run a suspend block tied to the composition
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@Composable
fun ErrorSnackbar(errorMessage: String?, snackbarHostState: SnackbarHostState, onShown: () -> Unit) {
LaunchedEffect(errorMessage) { // key: restart when the message changes
if (errorMessage != null) {
snackbarHostState.showSnackbar(errorMessage) // suspends until dismissed
onShown() // tell the ViewModel it was shown
}
}
}
How it behaves:
- On entering the composition, the block starts in a coroutine.
- If the key changes (
errorMessage), the old coroutine is cancelled and a new one starts. - When the composable leaves, the coroutine is cancelled.
LaunchedEffect(Unit)(a constant key) means "run once while this composable is in the tree".LaunchedEffect(true)orUnitwith a changing value read inside is the classic bug (below).
rememberCoroutineScope: launch from events
A LaunchedEffect is for work that begins when something enters composition. For work in response to a click, you need a scope you can launch from a callback:
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
@Composable
fun ScrollToTopButton(listState: LazyListState) {
val scope = rememberCoroutineScope()
Button(onClick = {
scope.launch { listState.animateScrollToItem(0) } // suspend function, from an event
}) { Text("Top") }
}
The scope is tied to the composable's lifecycle: cancelled when it leaves. Do not call scope.launch directly in the composable body, or you will start a new coroutine on every recomposition.
Why not just launch in the body?
@Composable
fun Bad(viewModel: StudentsViewModel) {
viewModel.load() // WRONG: runs on EVERY recomposition (scrolling, typing, animation...)
}
@Composable
fun Good(viewModel: StudentsViewModel) {
LaunchedEffect(Unit) { viewModel.load() } // runs once per entry
}
Even better, start loading from the ViewModel's init or when the first collector subscribes (lesson 61), so that your composable does not need to.
A LaunchedEffect with key = Unit that uses a changing value or callback will keep using the first one it captured. Example: a splash timer that calls onTimeout:
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberUpdatedState
import kotlinx.coroutines.delay
@Composable
fun SplashScreen(onTimeout: () -> Unit) {
val currentOnTimeout by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) { // do NOT restart the timer when onTimeout changes
delay(3_000)
currentOnTimeout() // but always call the LATEST onTimeout
}
// ...splash UI
}
(Add import androidx.compose.runtime.getValue for the by delegate.) Without rememberUpdatedState, if SplashScreen recomposed with a different onTimeout during the 3 seconds, the effect would call the old, stale lambda. Restarting the effect with LaunchedEffect(onTimeout) would reset the timer every time. rememberUpdatedState gives the best of both: no restart, no stale value.
When something needs cleanup, use DisposableEffect. Typical: lifecycle observers, broadcast receivers, sensor listeners, back-press callbacks.
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@Composable
fun OnLifecycleEvent(onEvent: (Lifecycle.Event) -> Unit) {
val currentOnEvent by rememberUpdatedState(onEvent)
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> currentOnEvent(event) }
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer) // required cleanup
}
}
}
@Composable
fun LoggingScreen() {
OnLifecycleEvent { event -> Log.d("Screen", "event=$event") }
}
(Add import androidx.compose.runtime.getValue. In recent lifecycle versions LocalLifecycleOwner lives in androidx.lifecycle.compose; let Studio's import suggestion pick the correct one for your version.)
DisposableEffect must end with onDispose { ... }, or it will not compile. It runs onDispose when the key changes (before starting the effect again) and when the composable leaves.
SideEffect: publish state to non-Compose code
SideEffect runs after every successful recomposition. Use it to keep an object that Compose does not manage in sync with Compose state, such as analytics user properties or a legacy View.
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
interface Analytics { fun setUserProperty(key: String, value: String) }
@Composable
fun TrackUserType(analytics: Analytics, isPremium: Boolean) {
SideEffect {
analytics.setUserProperty("user_type", if (isPremium) "premium" else "free")
}
}
It has no key and no coroutine: keep it tiny and quick, and never do slow work in it.
produceState: turn a non-Compose source into State
produceState launches a coroutine scoped to the composition and gives you a State<T> that updates as the coroutine sets value. It is how you load something once and expose it as state, handling loading and error:
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import kotlinx.coroutines.delay
sealed interface Result<out T> {
data object Loading : Result<Nothing>
data class Success<T>(val data: T) : Result<T>
data class Failure(val message: String) : Result<Nothing>
}
interface PriceApi { suspend fun price(item: String): Int }
@Composable
fun rememberPrice(item: String, api: PriceApi): State<Result<Int>> =
produceState<Result<Int>>(initialValue = Result.Loading, item, api) {
value = Result.Loading
value = try {
Result.Success(api.price(item))
} catch (e: Exception) {
Result.Failure(e.message ?: "Unknown error")
}
}
The keys (item, api) restart the producer when they change. Prefer a ViewModel with a StateFlow for screen-level data (lesson 61); produceState is best for self-contained composable-level sources (image loading, a one-off lookup, a callback API). The built-in collectAsState, collectAsStateWithLifecycle and observeAsState are implemented with the same idea.
derivedStateOf: less recomposition
When one state is computed from another that changes more often than your result does, derivedStateOf makes readers recompose only when the result changes.
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@Composable
fun showScrollToTop(listState: LazyListState): Boolean {
val show by remember { derivedStateOf { listState.firstVisibleItemIndex > 5 } }
return show // changes only when it crosses the threshold, not at every scroll position
}
Do not overuse it. If the input changes about as often as the output (derivedStateOf { count * 2 }), it adds overhead for nothing. It shines when many input values map to few outputs (a threshold, a boolean, a filtered list of a text query when typing). Remember to wrap in remember (and pass keys to remember if the lambda captures other values that can change).
snapshotFlow: Compose state to Flow
When you need Flow operators (debounce, distinct, filter) on Compose state, convert it with snapshotFlow:
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
@Composable
fun ReportScrolledPast(listState: LazyListState, onReached: (Int) -> Unit) {
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.filter { it > 0 && it % 10 == 0 }
.distinctUntilChanged()
.collect { onReached(it) }
}
}
The block inside snapshotFlow { } re-runs whenever any Compose state it reads changes, and the flow emits the new result. This is the standard way to send "user scrolled past item 10, 20..." to analytics, without recomposing anything.
1. Wrong key (restarts too often or never)
LaunchedEffect(Unit) { load(userId) } // BUG: userId changes, nothing reloads
LaunchedEffect(userId) { load(userId) } // correct: reload when userId changes
The rule: every value the effect uses and that can change should be a key (or accessed through rememberUpdatedState).
2. Effect that should be an event handler
Do not use a LaunchedEffect to react to a click by flipping a flag and observing it; just call the handler or launch from rememberCoroutineScope.
3. Side effect in composition
@Composable
fun Bad() {
Log.d("X", "composed") // fine for debugging only
database.insert(item) // WRONG: runs on every recomposition
counter++ // WRONG: mutating non-state in composition
}
4. Forgetting onDispose
A listener registered in DisposableEffect but not removed leaks the Activity.
5. Capturing a changing lambda without rememberUpdatedState
See Build 5.
6. Doing work in SideEffect that needs a coroutine or cleanup
Use LaunchedEffect or DisposableEffect.
7. One-shot events as state
A "navigate" or "show message" flag that stays true and fires again after rotation. Model events as state that is consumed (lesson 61) rather than a shared-flow that can be lost, or call onShown() after handling (as in Build 4).
Exercise 1: A countdown timer with correct effects
Write CountdownButton(seconds, onFinished) that shows the remaining seconds, counts down once per second when the user taps Start, and calls the latest onFinished at zero. Restart safely if the user taps Start again. Use rememberCoroutineScope, rememberUpdatedState and a Job.
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun CountdownButton(seconds: Int, onFinished: () -> Unit) {
val latestOnFinished by rememberUpdatedState(onFinished)
var remaining by remember { mutableIntStateOf(seconds) }
var job by remember { mutableStateOf<Job?>(null) }
val scope = rememberCoroutineScope()
Button(onClick = {
job?.cancel() // restart safely
remaining = seconds
job = scope.launch {
while (remaining > 0) {
delay(1_000)
remaining--
}
latestOnFinished()
}
}) {
Text(if (job?.isActive == true) "$remaining s" else "Start")
}
}
rememberUpdatedState avoids a stale callback; cancelling the previous Job avoids two timers running; the scope cancels the job automatically if the composable leaves. (job?.isActive is read in composition from a plain Job, which is not state, so the label may lag by one recomposition; in production keep a running boolean in state.)
Exercise 2: Fix the buggy effect
This composable should reload the student's details when studentId changes and show a snackbar when loading fails. It reloads only once and shows the snackbar on every recomposition. Fix it.
@Composable
fun StudentDetail(studentId: Int, vm: DetailViewModel, snackbarHostState: SnackbarHostState) {
LaunchedEffect(Unit) { vm.load(studentId) }
if (vm.error != null) {
scope.launch { snackbarHostState.showSnackbar(vm.error!!) }
}
}
@Composable
fun StudentDetail(studentId: Int, vm: DetailViewModel, snackbarHostState: SnackbarHostState) {
LaunchedEffect(studentId) { vm.load(studentId) } // key = the value that changes
val error = vm.error // state read once per composition
LaunchedEffect(error) { // effect, not a launch in the body
if (error != null) {
snackbarHostState.showSnackbar(error)
vm.errorShown() // consume the one-off message
}
}
}
Fixes: use studentId as the key so it reloads; replace the launch-in-body with a LaunchedEffect keyed on the error; consume the error so it is not shown again after rotation.
Exercise 3: Pick the API
For each, pick the best API: (a) register a BroadcastReceiver for connectivity changes and unregister on leaving; (b) show a "Back to top" button only after scrolling past item 3; (c) load a profile picture from a callback-based library and expose it as state; (d) send a debounced search query to the ViewModel as the user types in a TextField held in Compose state; (e) keep a legacy analytics SDK's "theme" property in sync with the app theme.
(a) DisposableEffect(context) with onDispose to unregister. (b) derivedStateOf { listState.firstVisibleItemIndex > 3 }. (c) produceState (or suspendCancellableCoroutine inside it) with keys of the picture ID. (d) LaunchedEffect with snapshotFlow { text }.debounce(300).distinctUntilChanged().collect { vm.search(it) }; alternatively hold the text in the ViewModel and debounce there (lesson 33). (e) SideEffect, since it must run after each successful recomposition and does not need cleanup or a coroutine.
Mistake #1: Doing real work in the composable body. Symptom: repeated network calls. Fix: effects or ViewModel.
Mistake #2: LaunchedEffect(Unit) using a changing value. Symptom: stale data. Fix: key it, or use rememberUpdatedState.
Mistake #3: Restarting an effect by keying on a lambda. Symptom: timer resets. Fix: rememberUpdatedState.
Mistake #4: DisposableEffect without proper cleanup. Symptom: leaks, duplicate callbacks. Fix: always remove listeners in onDispose.
Mistake #5: Storing large objects with rememberSaveable. Symptom: TransactionTooLargeException. Fix: ViewModel and database.
Mistake #6: Using derivedStateOf where inputs and outputs change equally. Symptom: extra overhead. Fix: compute directly.
Mistake #7: Forgetting remember around derivedStateOf. Symptom: recreated each recomposition. Fix: remember { derivedStateOf { ... } }.
Mistake #8: Hoisting state too high. Symptom: huge recompositions and parameter lists. Fix: hoist to the lowest common ancestor.
Mistake #9: Putting business logic in a state holder. Fix: UI logic in state holders, business logic in ViewModels.
Mistake #10: One-off events stored as a permanent flag. Symptom: repeat after rotation. Fix: consume events (lesson 61).
Question 1. Why can't you call a ViewModel load() directly in a composable body?
Composables can recompose many times, so the call would repeat. Side effects must be in an effect API (such as LaunchedEffect) or in event handlers.
Question 2. What happens to a LaunchedEffect when its key changes?
The running coroutine is cancelled and a new one starts with the new key.
Question 3. When do you use rememberCoroutineScope instead of LaunchedEffect?
When the coroutine must start in response to an event such as a click, because LaunchedEffect can only start when the composable enters or its key changes.
Question 4. What is rememberUpdatedState for?
It lets a long-running effect always call the latest value or lambda without restarting the effect.
Question 5. Name one correct use each for DisposableEffect, SideEffect, produceState and snapshotFlow.
DisposableEffect: register and unregister a lifecycle observer or receiver. SideEffect: publish state to non-Compose code after each recomposition. produceState: convert a callback or suspend source into State. snapshotFlow: turn Compose state, such as scroll position, into a Flow for debounce or analytics.
Go deeper: official resources
- Jetpack Compose for Android developers, Pathway 3: Advanced state and navigation
- Advanced state and side effects codelab
- Jetpack Compose state codelab
- Jetpack Compose architecture guide
Project goal: Build a study planner screen that uses a state holder class, a custom saver, and the main effects correctly.
Requirements:
PlannerStatestate holder: a list of tasks (title, minutes, done),add,toggle,remove, derivedtotalMinutes,doneCount, and aSaverso it survives rotation.- A
rememberPlannerState()function. - A Pomodoro timer composable (25 minutes shown as mm:ss, but use 10 seconds while testing) using
rememberCoroutineScope, a cancellableJobandrememberUpdatedStateforonFinished. - A
LaunchedEffectthat shows a snackbar when a task is added, keyed correctly. DisposableEffectthat logsON_PAUSE/ON_RESUMEand removes its observer.- A "Back to top" button using
derivedStateOfon aLazyListState. - A unit test for
PlannerState.
Stretch goal: use snapshotFlow to log "5 tasks done" once when doneCount reaches 5, with distinctUntilChanged.
package com.example.planner
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
data class Task(val id: Int, val title: String, val minutes: Int, val done: Boolean = false)
@Stable
class PlannerState(initial: List<Task> = emptyList(), nextId: Int = 1) {
val tasks = mutableStateListOf<Task>().apply { addAll(initial) }
private var nextId = nextId
val totalMinutes: Int get() = tasks.sumOf { it.minutes }
val doneCount: Int get() = tasks.count { it.done }
fun add(title: String, minutes: Int) {
if (title.isBlank()) return
tasks.add(Task(nextId++, title.trim(), minutes))
}
fun toggle(id: Int) {
val i = tasks.indexOfFirst { it.id == id }
if (i >= 0) tasks[i] = tasks[i].copy(done = !tasks[i].done)
}
fun remove(id: Int) { tasks.removeAll { it.id == id } }
companion object {
val Saver: Saver<PlannerState, *> = listSaver(
save = { s -> listOf(s.nextId) + s.tasks.flatMap { listOf(it.id, it.title, it.minutes, it.done) } },
restore = { list ->
val next = list[0] as Int
val tasks = list.drop(1).chunked(4).map {
Task(it[0] as Int, it[1] as String, it[2] as Int, it[3] as Boolean)
}
PlannerState(tasks, next)
}
)
}
}
@Composable
fun rememberPlannerState(): PlannerState =
rememberSaveable(saver = PlannerState.Saver) { PlannerState() }
package com.example.planner
import android.util.Log
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.lifecycle.LifecycleEventObserver
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun PlannerScreen(state: PlannerState = rememberPlannerState()) {
val snackbar = remember { SnackbarHostState() }
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val showTop by remember { derivedStateOf { listState.firstVisibleItemIndex > 5 } }
// Snackbar when a task is added: key on the count
val count = state.tasks.size
var lastCount by remember { mutableIntStateOf(count) }
LaunchedEffect(count) {
if (count > lastCount) snackbar.showSnackbar("Task added")
lastCount = count
}
// Lifecycle logging with cleanup
val owner = LocalLifecycleOwner.current
DisposableEffect(owner) {
val observer = LifecycleEventObserver { _, e -> Log.d("Planner", "event=$e") }
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
Scaffold(snackbarHost = { SnackbarHost(snackbar) }) { padding ->
Column(Modifier.padding(padding).padding(16.dp)) {
Text("Total: ${state.totalMinutes} min, done ${state.doneCount}")
PomodoroTimer(seconds = 10, onFinished = { Log.d("Planner", "Pomodoro finished") })
Button(onClick = { state.add("Read chapter ${state.tasks.size + 1}", 25) }) { Text("Add task") }
LazyColumn(state = listState) {
items(state.tasks, key = { it.id }) { task ->
Row {
Checkbox(task.done, { state.toggle(task.id) })
Text(task.title)
}
}
}
if (showTop) {
Button(onClick = { scope.launch { listState.animateScrollToItem(0) } }) { Text("Top") }
}
}
}
}
@Composable
fun PomodoroTimer(seconds: Int, onFinished: () -> Unit) {
val latest by rememberUpdatedState(onFinished)
var remaining by remember { mutableIntStateOf(seconds) }
var running by remember { mutableStateOf(false) }
var job by remember { mutableStateOf<Job?>(null) }
val scope = rememberCoroutineScope()
Button(onClick = {
job?.cancel()
remaining = seconds
running = true
job = scope.launch {
while (remaining > 0) { delay(1_000); remaining-- }
running = false
latest()
}
}) {
Text(if (running) "%d:%02d".format(remaining / 60, remaining % 60) else "Start focus")
}
}
package com.example.planner
import org.junit.Assert.assertEquals
import org.junit.Test
class PlannerStateTest {
@Test
fun totalsAndToggle() {
val s = PlannerState()
s.add("Maths", 30)
s.add("English", 20)
assertEquals(50, s.totalMinutes)
s.toggle(1)
assertEquals(1, s.doneCount)
s.remove(2)
assertEquals(30, s.totalMinutes)
}
}
Expected behaviour: adding tasks shows a snackbar and updates totals; the list survives rotation (the saver stores all tasks and the next id); the timer counts 10 → 0 and logs once, restarting cleanly when tapped again; the Top button appears after scrolling past six items; Logcat shows lifecycle events while the screen is visible, and the observer is removed when the screen leaves.
Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.
Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 54 project”.
On github.com click New repository, name it android-lesson-54-advanced-state-and-side-effects, set it to Public, leave Add a README unticked, and click Create repository.
Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.
Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉
Lesson 54 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: A Compose State of Mind
- Step-by-Step Build 1: Hoisting and a State Holder Class
- Step-by-Step Build 2: rememberSaveable and Savers
- Step-by-Step Build 3: Side Effects, the Big Picture
- Step-by-Step Build 4: LaunchedEffect and rememberCoroutineScope
- Step-by-Step Build 5: rememberUpdatedState
- Step-by-Step Build 6: DisposableEffect
- Step-by-Step Build 7: SideEffect and produceState
- Step-by-Step Build 8: derivedStateOf and snapshotFlow
- Step-by-Step Build 9: Classic Mistakes with Effects
- Guided Practice Exercises
- Common Mistakes (and How to Fix Them)
- Knowledge Check