In lesson 23 you met a painful fact: rotate the phone and your Activity is destroyed. You fixed a counter with rememberSaveable, which is fine for a few small numbers. But real screens hold a lot more: a list loaded from the network, a form with ten fields, the logic that decides what to show. Piling all of that into composables makes code that is hard to read, hard to test and easy to break.
The Android answer is architecture: a set of simple rules about where each kind of code lives. The first and most important tool is the ViewModel, a class that holds your screen's state and logic and survives rotation.
This lesson covers three items from Unit 4 of Google's official Android Basics with Compose course: Intro to App architecture, Architecture: The UI Layer and ViewModel and State in Compose.
By the end you will be able to:
- Explain separation of concerns and why apps are split into a UI layer and a data layer.
- Describe unidirectional data flow (state down, events up).
- Create a
ViewModel, get it in Compose withviewModel()and see that it survives configuration changes. - Model a whole screen as an immutable UI state data class.
- Expose state with
MutableStateFlowandasStateFlow, and collect it withcollectAsStateWithLifecycle(). - Move the Dessert Clicker state out of the composable into a ViewModel.
Big idea: composables should draw state and report events. They should not own important state or business rules.
- State,
rememberandrememberSaveable(lessons 17 and 23). - Data classes and
copy(lesson 11).copymakes a new object with a few fields changed, which you will use constantly with UI state. - Lambdas and function types (lesson 12). A parameter of type
() -> Unitis a callback. - The Dessert Clicker app from lesson 23. If you did not build it, you can follow along with the code in this lesson; the screen is small.
Imagine a restaurant with no separation of jobs: one person takes orders, cooks, washes dishes, handles money and cleans tables. It works for two customers and collapses at twenty. A real restaurant has a waiter (talks to customers), a kitchen (prepares food) and a storeroom (holds ingredients). Each can change without rewriting the others.
App architecture applies the same idea, called separation of concerns: each class has one reason to change.
| Benefit | What it means for you |
|---|---|
| Testability | Logic in a plain class can be tested on your laptop in milliseconds. Logic inside composables needs an emulator. |
| Survives change | State outside the Activity is not lost on rotation. |
| Teamwork | One person can build the screen while another builds the network code. |
| Readability | You know where to look for a bug. |
The layers
Google's guide to app architecture recommends layers:
+--------------------------------------------------+
| UI layer what the user sees |
| Composables <--- state --- ViewModel |
| | ^ |
| +---------- events --------+ |
+----------------------|---------------------------+
|
+----------------------v---------------------------+
| Data layer where data comes from |
| Repositories, network, database, files |
+--------------------------------------------------+
- The UI layer shows data on screen and handles user input. It has two parts: UI elements (your composables) and state holders (usually ViewModels) that decide what the UI should show.
- The data layer holds your app's data and business rules: a repository that reads from a web service or a database. You build it in lessons 29, 30 and 34.
- An optional domain layer sits between them for complex reusable logic (lesson 62).
Today you work in the UI layer. Dessert Clicker has no data layer yet.
Unidirectional data flow
The key rule of modern UI code is unidirectional data flow (UDF):
- State flows down: the ViewModel holds the state and hands it to the composables.
- Events flow up: when the user taps something, the composable calls a function on the ViewModel. It does not change the state itself.
state (read-only)
ViewModel ──────────────────▶ Composable draws it
▲ │
└──────── events ◀─────────────┘
(onClick calls viewModel.dessertClicked())
Why bother? There is exactly one place where state changes, so you can always answer "who changed this value?" Debugging becomes simple, and tests simply call the ViewModel's functions and check the state.
What is a ViewModel?
A ViewModel is a class from the Jetpack Lifecycle library with two helpful properties:
- It holds screen state and the logic that changes it.
- Android keeps the same instance alive across configuration changes. It is destroyed only when the user really leaves the screen (for example, presses Back out of the Activity).
So after rotation, the new Activity asks for the ViewModel and gets back the same object with the same state. This is cheaper and bigger than rememberSaveable: you can keep objects of any type, not just things that fit in a Bundle.
Be honest about its limits: a ViewModel does not survive process death. Anything critical must also be saved with SavedStateHandle (covered in lesson 25 and lesson 26) or persisted to storage (lessons 34 and 35).
The Empty Activity template does not always include the Compose ViewModel integration. Open gradle/libs.versions.toml and look for a lifecycle entry. If you do not see lifecycle-viewmodel-compose and lifecycle-runtime-compose, add them. Look up the latest stable versions on the official Lifecycle release page (https://developer.android.com/jetpack/androidx/releases/lifecycle) rather than copying a number from a tutorial. Since the catalog layout varies by Android Studio version, the shape below is what matters:
[versions]
lifecycle = "<latest stable>"
[libraries]
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
Then add them to app/build.gradle.kts and click Sync Now:
dependencies {
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
}
Lesson 28 explains this system fully. For today just follow the pattern.
Everything the screen displays goes into one immutable data class. For Dessert Clicker that is the revenue, the number sold and the dessert to show.
package com.example.dessertclicker.ui
data class DessertUiState(
val revenue: Int = 0,
val dessertsSold: Int = 0,
val currentDessertIndex: Int = 0,
val currentDessertName: String = "Cupcake"
)
Properties are val, not var. The class is immutable: you never change an existing state object, you create a new one with copy. That is what lets Compose reliably detect a change.
We store the dessert's name and index, not the whole Dessert object, to keep the example simple. In larger apps you may store the object itself. Reusing the data from lesson 23:
package com.example.dessertclicker
data class Dessert(val name: String, val price: Int, val startProductionAmount: Int)
val desserts = listOf(
Dessert("Cupcake", price = 5, startProductionAmount = 0),
Dessert("Donut", price = 10, startProductionAmount = 5),
Dessert("Eclair", price = 15, startProductionAmount = 20),
Dessert("Froyo", price = 30, startProductionAmount = 50),
Dessert("Gingerbread", price = 50, startProductionAmount = 100)
)
fun determineDessertIndex(dessertsSold: Int): Int {
var index = 0
for ((i, dessert) in desserts.withIndex()) {
if (dessertsSold >= dessert.startProductionAmount) index = i else break
}
return index
}
Create DessertViewModel.kt:
package com.example.dessertclicker.ui
import androidx.lifecycle.ViewModel
import com.example.dessertclicker.desserts
import com.example.dessertclicker.determineDessertIndex
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
class DessertViewModel : ViewModel() {
private val _uiState = MutableStateFlow(DessertUiState())
val uiState: StateFlow<DessertUiState> = _uiState.asStateFlow()
fun onDessertClicked() {
_uiState.update { current ->
val sold = current.dessertsSold + 1
val index = determineDessertIndex(sold)
current.copy(
revenue = current.revenue + desserts[current.currentDessertIndex].price,
dessertsSold = sold,
currentDessertIndex = index,
currentDessertName = desserts[index].name
)
}
}
fun onReset() {
_uiState.value = DessertUiState()
}
}
Read it slowly:
MutableStateFlow<DessertUiState>is an observable box that always holds one value and tells observers when it changes. It is part of Kotlin coroutines (the flow library) which you will meet properly in lessons 28 and 33._uiStateis private and mutable: only the ViewModel can change it.uiStateis public and read-only (StateFlow), created withasStateFlow(). Composables can read it but cannot assign to it. This is called a backing property and the underscore naming is the Kotlin convention for it.onDessertClicked()is the event handler.update { }computes the new value from the old one atomically, which is safe even if two things call it at once.- Note that the revenue uses the price of the dessert that was on screen when it was tapped (the old index), then the index moves on.
The ViewModel has no import from androidx.compose and no reference to the Activity or any composable. That is deliberate: it means you can test it with plain JUnit (lesson 25).
Never pass a
Context, an Activity or a composable into a ViewModel. The ViewModel outlives the Activity and would leak it, holding the old screen in memory. If you need a Context, you will useAndroidViewModelor dependency injection (lessons 30 and 63).
Now rewrite the screen. The composable receives state and callbacks, and does not know the ViewModel exists. This makes it previewable and testable.
package com.example.dessertclicker.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun DessertClickerApp(
modifier: Modifier = Modifier,
viewModel: DessertViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
DessertClickerScreen(
uiState = uiState,
onDessertClicked = viewModel::onDessertClicked,
onReset = viewModel::onReset,
modifier = modifier
)
}
@Composable
fun DessertClickerScreen(
uiState: DessertUiState,
onDessertClicked: () -> Unit,
onReset: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(onClick = onDessertClicked) {
Text(uiState.currentDessertName, style = MaterialTheme.typography.headlineMedium)
}
Text(
text = "Desserts sold: ${uiState.dessertsSold}",
modifier = Modifier.padding(top = 24.dp),
style = MaterialTheme.typography.titleLarge
)
Text(
text = "Total revenue: ₦${uiState.revenue}",
style = MaterialTheme.typography.titleLarge
)
OutlinedButton(onClick = onReset, modifier = Modifier.padding(top = 16.dp)) {
Text("Reset")
}
}
}
@Preview(showBackground = true)
@Composable
fun DessertClickerScreenPreview() {
DessertClickerScreen(
uiState = DessertUiState(revenue = 45, dessertsSold = 7, currentDessertName = "Donut"),
onDessertClicked = {},
onReset = {}
)
}
What each piece does
viewModel()returns the ViewModel scoped to the nearest owner (here, the Activity). The first call creates it, and later calls, including after rotation, return the same instance. Notice the default parameter value, so callers can replace it in tests or previews.collectAsStateWithLifecycle()converts theStateFlowinto ComposeState, collecting only while the screen is at least in the started state (lesson 23).byunwraps it so you can writeuiStateinstead ofuiState.value. Note the import ofgetValue.viewModel::onDessertClickedis a function reference: a shorter way to write{ viewModel.onDessertClicked() }.DessertClickerScreenis stateless: it takes state in and sends events out. The preview feeds it fake state, so you can design it without running the app.
Finally, call it from MainActivity:
setContent {
DessertClickerTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
DessertClickerApp(modifier = Modifier.padding(innerPadding))
}
}
}
Run it, tap seven times, rotate. The numbers survive, and there is no rememberSaveable in sight. Check Logcat: the Activity is destroyed and recreated, yet the ViewModel keeps its state.
Now press Home and run adb shell am kill com.example.dessertclicker, then reopen the app. The numbers are gone. That is the limitation: a ViewModel survives rotation, not process death. Lesson 25 shows how SavedStateHandle closes this gap, and lessons 34 and 35 show how to persist data permanently.
The pattern you just used has a name: state hoisting. You moved (hoisted) state out of a composable into a caller so the composable becomes stateless, and you replaced direct changes with callbacks. The recipe has two parts:
- State goes in as a parameter (
value,uiState). - Events go out as lambdas (
onValueChange,onClick).
Benefits: the composable is reusable, testable and previewable, and there is a single source of truth, so two parts of the UI can never disagree about the value.
A useful rule of thumb: hoist state to the lowest common ancestor that needs it. Tiny UI-only state such as "is this dropdown open" can stay in a composable with remember. State that affects business logic or needs to survive should live in the ViewModel. Lesson 54 explores this decision more.
Exercise 1: A to-do counter with a ViewModel
Build a small screen with a text field and an Add button. Each time the user adds a task, show it in a list underneath. The tasks must survive rotation.
Requirements:
TodoUiState(val tasks: List<String> = emptyList(), val draft: String = "").TodoViewModelwithonDraftChange(text),onAdd()(ignores blank text and clears the draft).- A stateless
TodoScreenand aTodoAppthat connects it.
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class TodoUiState(
val tasks: List<String> = emptyList(),
val draft: String = ""
)
class TodoViewModel : ViewModel() {
private val _uiState = MutableStateFlow(TodoUiState())
val uiState: StateFlow<TodoUiState> = _uiState.asStateFlow()
fun onDraftChange(text: String) {
_uiState.update { it.copy(draft = text) }
}
fun onAdd() {
_uiState.update { current ->
val text = current.draft.trim()
if (text.isEmpty()) current
else current.copy(tasks = current.tasks + text, draft = "")
}
}
}
@Composable
fun TodoApp(modifier: Modifier = Modifier, viewModel: TodoViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
TodoScreen(state, viewModel::onDraftChange, viewModel::onAdd, modifier)
}
@Composable
fun TodoScreen(
state: TodoUiState,
onDraftChange: (String) -> Unit,
onAdd: () -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier.padding(16.dp)) {
OutlinedTextField(
value = state.draft,
onValueChange = onDraftChange,
label = { Text("New task") }
)
Button(onClick = onAdd, modifier = Modifier.padding(vertical = 8.dp)) {
Text("Add")
}
LazyColumn {
items(state.tasks) { task -> Text(task, modifier = Modifier.padding(4.dp)) }
}
}
}
Why current.tasks + text: the + operator on a read-only list returns a new list, so the state object stays immutable. Mutating a list in place would not trigger an update.
Exercise 2: Spot the UDF violation
A teammate wrote this. Explain what is wrong and fix it.
@Composable
fun Counter(viewModel: CounterViewModel) {
var count by remember { mutableIntStateOf(0) }
Button(onClick = { count++; viewModel.log(count) }) {
Text("Count: $count")
}
}
The screen keeps a second copy of the state (count) in the composable while the ViewModel also receives it. There are now two sources of truth, and the composable's copy is lost on rotation. The composable also changes state directly instead of sending an event.
The fix: hold count in the ViewModel's UI state, expose it as a StateFlow, give the ViewModel an onIncrement() function, and make the composable take count: Int and onIncrement: () -> Unit. The composable then only draws and reports.
Add a Reset confirmation to Dessert Clicker: keep a showResetDialog: Boolean in DessertUiState, add onResetRequested() and onResetDismissed() to the ViewModel, and show an AlertDialog when it is true. Decide: should this boolean live in the ViewModel or in the composable with remember? Write one sentence defending your choice.
Mistake #1: Exposing the mutable flow. The wrong code: val uiState = MutableStateFlow(DessertUiState()). What happens: any composable can write to it, which breaks the one-way flow. The fix: private _uiState plus a public read-only uiState via asStateFlow().
Mistake #2: Mutating state in place. The wrong code: a var field or a MutableList mutated inside update. What happens: the object reference is the same, so observers may not be notified and the UI does not redraw. The fix: immutable val properties and copy.
Mistake #3: Storing a Context or composable in the ViewModel. What happens: memory leaks and crashes after rotation. The fix: pass plain data, use AndroidViewModel only if you truly need the application context, or inject dependencies.
Mistake #4: Creating the ViewModel with a constructor call. The wrong code: val vm = DessertViewModel() inside a composable. What happens: a new ViewModel on every recomposition and rotation, so state is lost. The fix: viewModel(), which returns the retained instance.
Mistake #5: Passing the ViewModel deep into the UI tree. What happens: every composable depends on the ViewModel, so none can be previewed or reused. The fix: get the ViewModel at the top-level screen composable and pass state and lambdas down.
Mistake #6: Forgetting getValue import for by. Symptom: Property delegate must have a getValue method. The fix: import androidx.compose.runtime.getValue.
Mistake #7: Thinking the ViewModel survives everything. What happens: state vanishes after process death. The fix: SavedStateHandle for small essential state, storage for real data.
Mistake #8: Business rules in the composable. What happens: logic such as "which dessert to show" is repeated or untestable. The fix: put rules in the ViewModel or a plain Kotlin function, and test them (lesson 25).
Question 1. What does separation of concerns mean, and name two benefits.
Each class or layer has one responsibility. Benefits include testability, easier maintenance, survival of state across configuration changes, and parallel teamwork.
Question 2. What are the two parts of the UI layer?
UI elements that render data (composables) and state holders (such as ViewModels) that hold the data and logic and expose it to the UI.
Question 3. Describe unidirectional data flow.
State flows down from the state holder to the UI, and events flow up from the UI to the state holder, which updates the state. The UI never changes the state directly, so there is a single source of truth.
Question 4. Why is _uiState private and uiState public?
So only the ViewModel can modify the state, while the UI can read it. The public property is a read-only StateFlow created with asStateFlow().
Question 5. Does a ViewModel survive a configuration change? A process death?
It survives configuration changes such as rotation because the system retains the instance. It does not survive process death, so essential state must also be saved with SavedStateHandle or persisted to storage.
Go deeper: official resources
- Android Basics with Compose, Unit 4, Pathway 1: Intro to App architecture, Architecture: The UI layer and ViewModel and State in Compose.
- Guide to app architecture
- UI layer
- State holders and UI state
Project goal: Rebuild a small bill splitter with proper architecture, and prove that it survives rotation.
Requirements:
BillUiState(billAmount: String, people: Int, tipPercent: Int)with computed read-only propertiestipAmount,totalandperPerson.BillViewModelwithonBillChange(String),onPeopleChange(Int)(never below 1) andonTipChange(Int)(0 to 30).- A stateless
BillScreenplus a connectedBillApp. - Invalid input (blank or non-numeric bill) is treated as 0 and never crashes.
- A preview with sample state.
Stretch goal: write down, in two sentences, why the computed values are properties of the state class and not stored fields.
package com.example.billsplit
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class BillUiState(
val billAmount: String = "",
val people: Int = 1,
val tipPercent: Int = 10
) {
private val bill: Double get() = billAmount.toDoubleOrNull() ?: 0.0
val tipAmount: Double get() = bill * tipPercent / 100.0
val total: Double get() = bill + tipAmount
val perPerson: Double get() = total / people
}
class BillViewModel : ViewModel() {
private val _uiState = MutableStateFlow(BillUiState())
val uiState: StateFlow<BillUiState> = _uiState.asStateFlow()
fun onBillChange(text: String) = _uiState.update { it.copy(billAmount = text) }
fun onPeopleChange(count: Int) = _uiState.update { it.copy(people = count.coerceAtLeast(1)) }
fun onTipChange(percent: Int) = _uiState.update { it.copy(tipPercent = percent.coerceIn(0, 30)) }
}
package com.example.billsplit
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.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun BillApp(modifier: Modifier = Modifier, viewModel: BillViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
BillScreen(
state = state,
onBillChange = viewModel::onBillChange,
onPeopleChange = viewModel::onPeopleChange,
onTipChange = viewModel::onTipChange,
modifier = modifier
)
}
@Composable
fun BillScreen(
state: BillUiState,
onBillChange: (String) -> Unit,
onPeopleChange: (Int) -> Unit,
onTipChange: (Int) -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier.padding(16.dp)) {
OutlinedTextField(
value = state.billAmount,
onValueChange = onBillChange,
label = { Text("Bill amount (₦)") }
)
Row(modifier = Modifier.padding(vertical = 8.dp)) {
Button(onClick = { onPeopleChange(state.people - 1) }) { Text("-") }
Text(" ${state.people} people ", modifier = Modifier.padding(top = 12.dp))
Button(onClick = { onPeopleChange(state.people + 1) }) { Text("+") }
}
Text("Tip: ${state.tipPercent}%")
Slider(
value = state.tipPercent.toFloat(),
onValueChange = { onTipChange(it.toInt()) },
valueRange = 0f..30f
)
Text("Tip amount: ₦%.2f".format(state.tipAmount))
Text("Total: ₦%.2f".format(state.total))
Text("Each pays: ₦%.2f".format(state.perPerson))
}
}
Stretch answer: computed values derived from other fields are always consistent with them, because they cannot be forgotten when the inputs change. Storing them as fields would create copies that could drift out of sync, which is the very problem a single source of truth prevents. (String.format uses the device locale for the decimal separator, a trap lesson 20 explained.)
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 24 project”.
On github.com click New repository, name it android-lesson-24-app-architecture-and-the-viewmodel, 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 24 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: Why Architecture?
- Step-by-Step Build 1: Dependencies
- Step-by-Step Build 2: Model the Screen with a UI State Class
- Step-by-Step Build 3: The ViewModel
- Step-by-Step Build 4: Connect the ViewModel to Compose
- State Hoisting
- Guided Practice Exercises
- Common Beginner Mistakes (and How to Fix Them)
- Knowledge Check