Techbase Academy
Android · Lesson 54

Advanced State and Side Effects

15 phasesStudy Planner with a State HolderAndroid Studio
Phase 1 of 15
Lesson Introduction

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 remember and a custom Saver.
  • Use rememberSaveable with parcelable, listSaver and mapSaver.
  • Explain the composable lifecycle: enter, recompose, leave.
  • Use the effect APIs correctly: LaunchedEffect, rememberCoroutineScope, rememberUpdatedState, DisposableEffect, SideEffect, produceState, derivedStateOf and snapshotFlow.
  • 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.

✏️ Your Task
Practise what you just learned about Lesson Introduction. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 2 of 15
Prerequisite Concepts
  • 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).
✏️ Your Task
Practise what you just learned about Prerequisite Concepts. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 3 of 15
Conceptual Understanding: A Compose State of Mind

Three ideas, repeated in all of Compose:

  1. UI = f(state). The screen is a function of state. Change state, and Compose re-runs only the parts that read it.
  2. 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).
  3. State flows down, events flow up (unidirectional data flow).

The composable lifecycle

text
          +-----------+      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.

✏️ Your Task
Practise what you just learned about Conceptual Understanding: A Compose State of Mind. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 4 of 15
Step-by-Step Build 1: Hoisting and a State Holder Class

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

kotlin
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

RegistrationState.kt
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 mutableStateOf properties, so any composable that reads state.email recomposes when it changes. Properties like emailError are derived from state, so they update automatically and never go stale.
  • Saver / listSaver explain how to turn the object into something the Bundle can store (a list of strings) and back. That makes it survive rotation and process death, as rememberSaveable does 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).
  • @Stable is a promise to the compiler that the class notifies Compose of changes (lesson 57 explains stability).

The composable now reads like UI

kotlin
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:

RegistrationStateTest.kt
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.)

✏️ Your Task
Practise what you just learned about Step-by-Step Build 1: Hoisting and a State Holder Class. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 5 of 15
Step-by-Step Build 2: rememberSaveable and Savers

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

kotlin
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

kotlin
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).

✏️ Your Task
Practise what you just learned about Step-by-Step Build 2: rememberSaveable and Savers. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 6 of 15
Step-by-Step Build 3: Side Effects, the Big Picture

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

text
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 { ... }
✏️ Your Task
Practise what you just learned about Step-by-Step Build 3: Side Effects, the Big Picture. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 7 of 15
Step-by-Step Build 4: LaunchedEffect and rememberCoroutineScope

LaunchedEffect: run a suspend block tied to the composition

kotlin
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) or Unit with 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:

kotlin
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?

kotlin
@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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 4: LaunchedEffect and rememberCoroutineScope. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 8 of 15
Step-by-Step Build 5: rememberUpdatedState

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:

kotlin
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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 5: rememberUpdatedState. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 9 of 15
Step-by-Step Build 6: DisposableEffect

When something needs cleanup, use DisposableEffect. Typical: lifecycle observers, broadcast receivers, sensor listeners, back-press callbacks.

kotlin
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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 6: DisposableEffect. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 10 of 15
Step-by-Step Build 7: SideEffect and produceState

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.

kotlin
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:

kotlin
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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 7: SideEffect and produceState. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 11 of 15
Step-by-Step Build 8: derivedStateOf and snapshotFlow

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.

kotlin
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:

kotlin
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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 8: derivedStateOf and snapshotFlow. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 12 of 15
Step-by-Step Build 9: Classic Mistakes with Effects

1. Wrong key (restarts too often or never)

kotlin
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

kotlin
@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).

✏️ Your Task
Practise what you just learned about Step-by-Step Build 9: Classic Mistakes with Effects. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 13 of 15
Guided Practice Exercises
🎯 Your Challenge

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.

kotlin
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.)

🎯 Your Challenge

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.

kotlin
@Composable
fun StudentDetail(studentId: Int, vm: DetailViewModel, snackbarHostState: SnackbarHostState) {
 LaunchedEffect(Unit) { vm.load(studentId) }
 if (vm.error != null) {
 scope.launch { snackbarHostState.showSnackbar(vm.error!!) }
 }
}
kotlin
@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.

🎯 Your Challenge

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.

✏️ Your Task
Practise what you just learned about Guided Practice Exercises. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 14 of 15
Common Mistakes (and How to Fix Them)

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).

✏️ Your Task
Practise what you just learned about Common Mistakes (and How to Fix Them). Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 15 of 15
Knowledge Check

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

✏️ Your Task
Practise what you just learned about Knowledge Check. Open Android Studio, type the examples by hand, change a value, and observe what happens.
🏗️ Build It — Mini Project
Study Planner with a State Holder

Project goal: Build a study planner screen that uses a state holder class, a custom saver, and the main effects correctly.

Requirements:

  1. PlannerState state holder: a list of tasks (title, minutes, done), add, toggle, remove, derived totalMinutes, doneCount, and a Saver so it survives rotation.
  2. A rememberPlannerState() function.
  3. A Pomodoro timer composable (25 minutes shown as mm:ss, but use 10 seconds while testing) using rememberCoroutineScope, a cancellable Job and rememberUpdatedState for onFinished.
  4. A LaunchedEffect that shows a snackbar when a task is added, keyed correctly.
  5. DisposableEffect that logs ON_PAUSE/ON_RESUME and removes its observer.
  6. A "Back to top" button using derivedStateOf on a LazyListState.
  7. A unit test for PlannerState.

Stretch goal: use snapshotFlow to log "5 tasks done" once when doneCount reaches 5, with distinctUntilChanged.

PlannerState.kt
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() }
PlannerScreen.kt
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")
 }
}
PlannerStateTest.kt
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.

1

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.

2

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”.

3

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.

4

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.

5

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
All 72 lessons in Google Android Studio ·Techbase Academy ·© 2025–2026 Techbase Consultant Services, Ibadan
Esc
↑ ↓ to moveEnter to openEsc to close