The UI layer has one job: show application data on the screen and serve as the point of user interaction. Whenever data changes, the UI must update. Whenever the user acts, the UI must tell someone who can respond. In lesson 60 you separated the layers. This lesson goes deep inside the top one.
It covers the UI Layer, The UI layer - MAD Skills, Handling UI events, State holders and UI state and State production items of the Modern Android App Architecture programme (https://developer.android.com/courses/android-architecture), based on the official pages:
- UI layer: https://developer.android.com/topic/architecture/ui-layer
- UI events: https://developer.android.com/topic/architecture/ui-layer/events
- State holders and UI state: https://developer.android.com/topic/architecture/ui-layer/stateholders
- State production: https://developer.android.com/topic/architecture/ui-layer/state-production
By the end you will be able to:
- Design UI state classes for loading, content and error.
- Choose between a ViewModel and a plain state holder.
- Produce state with
StateFlow,stateIn,combine,produceStateandmutableStateOf. - Handle UI events correctly, including one-off events modelled as state.
- Preserve state across configuration changes and process death with
SavedStateHandle. - Distinguish UI logic from business logic and place each properly.
Big idea: the UI layer is a pipeline: data in → state production → UI state → render → user events → back to the state holder. If you can name every stage, you can debug any screen.
- Architecture principles and layers (lesson 60).
- ViewModel,
StateFlow,collectAsStateWithLifecycle(lessons 25 and 33). - Flow operators (lesson 33).
- State hoisting (lessons 19 and 37).
- Navigation arguments (lessons 26 and 55).
Data layer
│ Flow / suspend
▼
State holder (ViewModel or plain class)
│ produces
▼
UI state (immutable data class)
│ collected by
▼
UI elements (composables)
│ user events
└──────────► back to the state holder
| Part | Role |
|---|---|
| UI state | An immutable snapshot of everything the screen needs |
| State holder | Owns the state, exposes it, and handles events |
| UI elements | Render state and report events |
Designing UI state
A good UI state:
- is immutable (
vals in adata class), - contains only what the screen shows,
- makes impossible states impossible.
Version A: a flat class with flags.
data class FeeUiState(
val loading: Boolean = true,
val payments: List<Payment> = emptyList(),
val error: String? = null
)
This allows loading = true and error != null at the same time, a combination that makes no sense.
Version B: a sealed hierarchy.
sealed interface FeeUiState {
data object Loading : FeeUiState
data class Success(val payments: List<Payment>, val summary: FeeSummary) : FeeUiState
data class Error(val message: String) : FeeUiState
}
Now exactly one case is possible at a time, and a when expression must handle all of them:
@Composable
fun FeeScreen(state: FeeUiState, onRetry: () -> Unit) {
when (state) {
FeeUiState.Loading -> CircularProgressIndicator()
is FeeUiState.Error -> ErrorMessage(state.message, onRetry)
is FeeUiState.Success -> PaymentList(state.payments, state.summary)
}
}
Which to choose? Use a sealed state when the cases are truly exclusive. Use a flat data class when the pieces vary independently (a screen showing a list and a refresh indicator and a snackbar). Many real screens use a flat class with a few sealed parts. The official guidance only asks that state be immutable and complete.
Properties of good state
| Do | Do not |
|---|---|
Expose StateFlow<UiState> |
Expose MutableStateFlow |
Use immutable List |
Use MutableList in state |
Keep display-ready values when helpful ("₦150,000") |
Put raw Context or View references in state |
| Have a default initial state | Leave the screen with nothing to render |
There are two kinds of state holder.
| ViewModel | Plain state holder class | |
|---|---|---|
| Lives | Screen level (survives configuration changes) | Composable level, created with remember |
| Scope | The whole screen | A part of the UI (a form, a search bar, a scaffold) |
| Needs the data layer | Yes (business logic, repositories) | Usually not, UI logic only |
| Example | FeeViewModel |
rememberSearchState(), ScaffoldState |
The guidance: business logic (rules about fees, repository calls) belongs in a ViewModel; UI logic (how to show something, scroll positions, expansion states) belongs in a plain state holder or composable state.
A plain state holder
@Stable
class SearchState(initialQuery: String = "") {
var query by mutableStateOf(initialQuery)
private set
val isActive: Boolean get() = query.isNotBlank()
fun onQueryChange(value: String) { query = value }
fun clear() { query = "" }
companion object {
val Saver: Saver<SearchState, String> = Saver(
save = { it.query },
restore = { SearchState(it) }
)
}
}
@Composable
fun rememberSearchState(): SearchState =
rememberSaveable(saver = SearchState.Saver) { SearchState() }
Usage:
@Composable
fun StudentSearchBar(onSearch: (String) -> Unit) {
val state = rememberSearchState()
OutlinedTextField(
value = state.query,
onValueChange = {
state.onQueryChange(it)
onSearch(it)
},
trailingIcon = { if (state.isActive) IconButton(onClick = state::clear) { Icon(Icons.Default.Clear, "Clear") } }
)
}
The holder is plain Kotlin with Compose state: no ViewModel is needed, because nothing here involves the data layer. Using rememberSaveable with a custom Saver keeps the query across rotation.
Rule of thumb: if the logic needs a repository or must outlive the composition, use a ViewModel. If it only manipulates what is shown, use a plain holder. They can work together: the ViewModel holds screen state and the plain holder wraps UI-only details.
State production is how you turn data (flows, suspend calls, user input) into UI state. The official page describes it as a pipeline with inputs (events, data) and outputs (state).
Pattern 1: Update a MutableStateFlow in response to events
class FeeViewModel(private val repository: PaymentsRepository) : ViewModel() {
private val _uiState = MutableStateFlow<FeeUiState>(FeeUiState.Loading)
val uiState: StateFlow<FeeUiState> = _uiState.asStateFlow()
fun refresh() {
viewModelScope.launch {
_uiState.value = FeeUiState.Loading
_uiState.value = try {
val payments = repository.payments()
FeeUiState.Success(payments, summarise(payments))
} catch (e: IOException) {
FeeUiState.Error("Could not load payments")
}
}
}
}
Good for one-shot loads and events.
Pattern 2: Transform a data stream with stateIn
When the data layer exposes a Flow (for example from Room), convert it directly:
class FeeViewModel(repository: PaymentsRepository) : ViewModel() {
val uiState: StateFlow<FeeUiState> = repository.paymentsStream() // Flow<List<Payment>>
.map<List<Payment>, FeeUiState> { FeeUiState.Success(it, summarise(it)) }
.catch { emit(FeeUiState.Error("Could not load payments")) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FeeUiState.Loading
)
}
stateIn turns a cold flow into a hot StateFlow. WhileSubscribed(5_000) keeps the upstream running for 5 seconds after the last collector leaves. This survives a screen rotation without restarting the work, but stops when the user really leaves the screen. It is the recommended default in the official material.
Pattern 3: Combine several inputs
A screen with a search query and a filter uses combine:
class StudentsViewModel(repository: StudentsRepository) : ViewModel() {
private val query = MutableStateFlow("")
private val onlyOwing = MutableStateFlow(false)
val uiState: StateFlow<StudentsUiState> =
combine(repository.studentsStream(), query, onlyOwing) { students, q, owing ->
val shown = students
.filter { it.name.contains(q, ignoreCase = true) }
.filter { !owing || it.balance > 0 }
StudentsUiState(query = q, onlyOwing = owing, students = shown)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), StudentsUiState())
fun onQueryChange(value: String) { query.value = value }
fun onOnlyOwingChange(value: Boolean) { onlyOwing.value = value }
}
Every input change produces a new complete state, so the UI never sees half-updated data.
Pattern 4: Compose-only production with produceState
For small, UI-local sources, produceState converts a non-Compose source into Compose State:
@Composable
fun rememberNow(): State<LocalTime> = produceState(initialValue = LocalTime.now()) {
while (true) {
delay(1_000)
value = LocalTime.now()
}
}
Another useful tool is snapshotFlow, which turns Compose state into a flow, as you saw in lesson 54.
Where do you collect?
@Composable
fun FeeRoute(viewModel: FeeViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
FeeScreen(state = state, onRetry = viewModel::refresh)
}
Always use collectAsStateWithLifecycle (from lifecycle-runtime-compose) so that collection stops when the app is in the background, saving work and battery.
Surviving process death with SavedStateHandle
A ViewModel survives configuration changes but not the system killing your process. Small, important pieces of state (a search query, a selected id) can be saved with SavedStateHandle:
class StudentsViewModel(
private val savedState: SavedStateHandle,
repository: StudentsRepository
) : ViewModel() {
val query: StateFlow<String> = savedState.getStateFlow("query", "")
fun onQueryChange(value: String) { savedState["query"] = value }
}
Keep what you store small (strings, ids, flags). The big data is reloaded from the data layer.
A UI event is something that happens in or to the UI: a click, a text change, a swipe. The official page splits events by who should handle them:
| Event | Handled by | Example |
|---|---|---|
| Needs business logic | ViewModel | "Pay" clicked: validate and record payment |
| Needs UI behaviour logic | UI (composable or plain holder) | Show a snackbar, navigate, scroll to top |
Business-logic events: ViewModel functions
fun onPayClicked(studentId: Int, amount: Int) {
viewModelScope.launch {
repository.recordPayment(studentId, amount)
}
}
The UI calls viewModel.onPayClicked(...). The ViewModel changes state; the UI recomposes.
UI-behaviour events: handle in the UI
@Composable
fun StudentRow(student: Student, onPay: () -> Unit, onOpen: () -> Unit) {
// navigation and "open details" are UI behaviour; the ViewModel need not know
Row(Modifier.clickable(onClick = onOpen)) { /* ... */ }
}
The one-off events problem
Some outcomes are "do it once": show a snackbar "Payment saved", navigate after saving. The old approach used SharedFlow or Channel events from the ViewModel. The current official recommendation is: model one-off events as state, and have the UI consume them.
data class PayUiState(
val saving: Boolean = false,
val userMessage: String? = null, // "Payment saved", consumed once
val savedAndFinished: Boolean = false // navigation trigger
)
class PayViewModel(private val repository: PaymentsRepository) : ViewModel() {
private val _uiState = MutableStateFlow(PayUiState())
val uiState: StateFlow<PayUiState> = _uiState.asStateFlow()
fun pay(studentId: Int, amount: Int) {
viewModelScope.launch {
_uiState.update { it.copy(saving = true) }
repository.recordPayment(studentId, amount)
_uiState.update { it.copy(saving = false, userMessage = "Payment saved", savedAndFinished = true) }
}
}
fun messageShown() = _uiState.update { it.copy(userMessage = null) }
}
The UI shows the message and then tells the ViewModel it was consumed:
@Composable
fun PayRoute(viewModel: PayViewModel, snackbar: SnackbarHostState, onFinished: () -> Unit) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(state.userMessage) {
state.userMessage?.let {
snackbar.showSnackbar(it)
viewModel.messageShown()
}
}
LaunchedEffect(state.savedAndFinished) {
if (state.savedAndFinished) onFinished()
}
PayScreen(state = state, onPay = viewModel::pay)
}
Why state instead of an event stream? Events sent while the UI is not listening (during rotation, for example) can be lost or delivered twice with channels. State is retained until consumed, so it is never lost, and it can be rendered repeatedly without side effects if you clear it after use.
A rule of thumb: the ViewModel says what happened (state). The UI decides how to show it (a snackbar, a dialog, a navigation call).
Text fields and state
A text field is an event source that must update state synchronously to avoid cursor jumps. For simple fields, keep the field in UI state (rememberSaveable) or, when business logic needs every keystroke (validation), keep it in the ViewModel as a StateFlow<String> and read it with collectAsStateWithLifecycle. If you use TextFieldState (the newer Compose text API) hold it in a plain state holder. Do not debounce the value that the field displays; debounce only the work that reacts to it.
| Question | Business logic | UI logic |
|---|---|---|
| What does it need? | Data and domain rules | Screen elements and resources |
| Example | "A student with a balance over 0 is owing" | "Show the owing badge in red" |
| Home | Domain/data layer (via ViewModel) | Composable or plain state holder |
| Test | JUnit | Compose UI test |
Mixing them is the cause of most "my ViewModel is 800 lines long" problems.
// Business logic (domain)
fun Student.isOwing(): Boolean = balance > 0
// UI logic (composable)
@Composable
fun OwingBadge(student: Student) {
if (student.isOwing()) {
AssistChip(onClick = {}, label = { Text("Owing") }, colors = AssistChipDefaults.assistChipColors(
containerColor = MaterialTheme.colorScheme.errorContainer))
}
}
Before the exercises, trace one user action through the whole pipeline, because being able to narrate the flow is the best debugging skill in this layer. Suppose a bursar taps Mark as paid on a student row in the Students screen.
- The UI element reports an event. The row's button calls a lambda passed from above,
onMarkPaid(student.id). The row does not know what happens next, and it holds no state of its own about payment. - The route composable forwards it.
StudentsRoutereceivesviewModel::markPaidas the callback. This is a business-logic event, because it changes stored data, so it goes to the ViewModel rather than being handled in the UI. - The state holder runs business logic.
markPaidlaunches a coroutine inviewModelScope, asks the repository to record the payment, and then writes a message into its message flow. - The data layer changes the source of truth. The repository updates the database. Because the screen collects a
Flowfrom that database, the list re-emits on its own. - State is produced.
combinemerges the new student list, the unchanged query and filter, and the new message into a freshSuccessstate.stateInpublishes it. - The UI renders.
collectAsStateWithLifecyclehands the new state to Compose. The row now shows a cleared balance, and aLaunchedEffectkeyed on the message shows a snackbar and callsmessageShown().
If any step misbehaves you can look at exactly one boundary: is the event reaching the ViewModel (add a log or a breakpoint in markPaid)? Is the repository changing the data (check the database with the Database Inspector from lesson 44)? Is the state being produced (inspect uiState.value in a test)? Is the UI reading the right state (check that you are collecting the same StateFlow and not a copy)? Debugging by boundary is far quicker than staring at a whole screen.
Two warnings about state production
First, never create a new StateFlow inside a getter. A property such as val uiState get() = flow.stateIn(...) builds a new pipeline each time it is read, so the UI would restart your work on every recomposition. Define the property once, as a value, as in the examples above.
Second, keep the initial value honest. stateIn needs an initialValue, and many learners pass an empty Success, which makes the screen flash "no students" before the data arrives. Use Loading (or a Success with a refreshing flag when you already have cached content), so that what the user sees matches reality.
Exercise 1: Make impossible states impossible
This state allows loading = true with a non-empty error. Redesign it for a screen that shows a list of students, an error, or a spinner.
data class StudentsUiState(
val loading: Boolean = false,
val students: List<Student> = emptyList(),
val error: String? = null
)
sealed interface StudentsUiState {
data object Loading : StudentsUiState
data class Success(val students: List<Student>) : StudentsUiState
data class Error(val message: String) : StudentsUiState
}
Now only one case exists at a time and when forces every case to be handled. (If you also need a pull-to-refresh indicator while content is visible, keep a refreshing: Boolean inside Success.)
Exercise 2: Pick the holder
For each, choose ViewModel or plain state holder: (a) the loaded list of students; (b) whether a filter panel is expanded; (c) the text in a search box that must survive rotation; (d) the result of saving a payment.
(a) ViewModel (data layer, screen state). (b) Plain state or rememberSaveable (pure UI). (c) Plain state holder with rememberSaveable, or SavedStateHandle if the ViewModel filters with it. (d) ViewModel, because it follows a business operation.
Exercise 3: Replace the channel
Convert this one-off event approach into state:
private val _events = Channel<String>()
val events = _events.receiveAsFlow()
fun save() { viewModelScope.launch { repo.save(); _events.send("Saved") } }
data class SaveUiState(val message: String? = null)
private val _uiState = MutableStateFlow(SaveUiState())
val uiState = _uiState.asStateFlow()
fun save() {
viewModelScope.launch {
repo.save()
_uiState.update { it.copy(message = "Saved") }
}
}
fun messageShown() = _uiState.update { it.copy(message = null) }
The UI shows message in a LaunchedEffect(state.message) and calls messageShown() afterwards, so it is neither lost during rotation nor shown twice.
Mistake #1: Exposing MutableStateFlow. Fix: expose StateFlow with asStateFlow().
Mistake #2: Using collectAsState instead of collectAsStateWithLifecycle. Fix: the lifecycle version stops collection in the background.
Mistake #3: Flags that allow impossible states. Fix: a sealed interface where cases are exclusive.
Mistake #4: Doing heavy mapping in the composable. Fix: produce display-ready state in the state holder.
Mistake #5: Passing Context, NavController or SnackbarHostState into the ViewModel. Fix: keep UI objects in the UI; send state.
Mistake #6: One-off events through SharedFlow or Channel that get lost. Fix: model events as state and consume them.
Mistake #7: Starting stateIn with Eagerly for everything. Fix: use WhileSubscribed(5_000).
Mistake #8: Storing large data in SavedStateHandle. Fix: save ids; reload data.
Mistake #9: Creating a ViewModel for every tiny widget. Fix: use a plain state holder.
Mistake #10: Updating StateFlow with a mutable object in place. Fix: copy: update { it.copy(...) }.
Question 1. What are the three parts of the UI layer?
UI state, state holders and UI elements.
Question 2. Why use WhileSubscribed(5_000)?
It keeps the upstream running through short interruptions like rotation but stops it when the user has really left, saving resources.
Question 3. When should a plain state holder be preferred to a ViewModel?
When the logic is UI-only and does not need the data layer or to outlive the composition's screen scope.
Question 4. How should a ViewModel tell the UI to show a snackbar?
By setting a state value (such as userMessage); the UI shows it and calls a function to clear it.
Question 5. What does SavedStateHandle protect against?
Loss of small pieces of state when the system kills the app process while it is in the background.
Go deeper: official resources
- Modern Android App Architecture programme
- UI layer
- UI events
- State holders and UI state
- State production
Project goal: Build a Students screen that demonstrates every part of the UI layer.
Requirements:
- A
StudentsRepositoryinterface and a fake with aFlow<List<Student>>that emits an update every few seconds. - A
StudentsViewModelthat combines the repository flow, a search query (backed bySavedStateHandle) and an "only owing" filter, and produces aStateFlow<StudentsUiState>withstateInandWhileSubscribed(5_000). - A sealed
StudentsUiStatewithLoading,SuccessandError. - A plain
rememberSearchState-style holder for the search field's UI behaviour (clear button). - A one-off message ("Marked as paid") implemented as state and consumed with a snackbar.
- Collection with
collectAsStateWithLifecycle. - Tests: the ViewModel's filtering (with fake repository and
runTest), and a UI test forSuccessandError.
Stretch goal: add produceState to show a "last updated 12 s ago" label that ticks every second without involving the ViewModel.
The ViewModel (core of the answer):
sealed interface StudentsUiState {
data object Loading : StudentsUiState
data class Success(
val query: String,
val onlyOwing: Boolean,
val students: List<Student>,
val message: String? = null
) : StudentsUiState
data class Error(val message: String) : StudentsUiState
}
class StudentsViewModel(
private val savedState: SavedStateHandle,
private val repository: StudentsRepository
) : ViewModel() {
private val query = savedState.getStateFlow("query", "")
private val onlyOwing = MutableStateFlow(false)
private val message = MutableStateFlow<String?>(null)
val uiState: StateFlow<StudentsUiState> =
combine(repository.studentsStream(), query, onlyOwing, message) { students, q, owing, msg ->
val shown = students
.filter { it.name.contains(q, ignoreCase = true) }
.filter { !owing || it.balance > 0 }
StudentsUiState.Success(q, owing, shown, msg) as StudentsUiState
}
.catch { emit(StudentsUiState.Error("Could not load students")) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), StudentsUiState.Loading)
fun onQueryChange(value: String) { savedState["query"] = value }
fun onOnlyOwingChange(value: Boolean) { onlyOwing.value = value }
fun markPaid(id: Int) {
viewModelScope.launch {
repository.markPaid(id)
message.value = "Marked as paid"
}
}
fun messageShown() { message.value = null }
}
A ViewModel test:
class StudentsViewModelTest {
@get:Rule val mainRule = MainDispatcherRule()
@Test
fun filter_only_owing_hides_cleared_students() = runTest {
val repo = FakeStudentsRepository(
listOf(Student(1, "Ada", balance = 0), Student(2, "Tunde", balance = 5_000))
)
val vm = StudentsViewModel(SavedStateHandle(), repo)
val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.uiState.collect {} }
vm.onOnlyOwingChange(true)
val state = vm.uiState.value as StudentsUiState.Success
assertEquals(listOf("Tunde"), state.students.map { it.name })
job.cancel()
}
}
The test collects in the background because stateIn(WhileSubscribed) only runs while something is subscribed, which is exactly why this default saves work in the real app.
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 61 project”.
On github.com click New repository, name it android-lesson-61-the-ui-layer-state-holders-state-productio, 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 61 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: The Parts of the UI Layer
- Step-by-Step Build 1: ViewModel or Plain State Holder?
- Step-by-Step Build 2: Producing State
- Step-by-Step Build 3: Handling UI Events
- Step-by-Step Build 4: UI Logic vs Business Logic
- Step-by-Step Build 5: Putting the Pipeline Together
- Guided Practice Exercises
- Common Beginner Mistakes (and How to Fix Them)
- Knowledge Check