Event Handling in Jetpack Compose
Learning Outcomes
After completing this lecture and lab, you will be able to:
1. Explain the Unidirectional Data Flow (UDF) pattern and its role in Compose event handling.
2. Implement click, text input, toggle, and list-item events using correct Compose APIs.
3. Apply state hoisting to separate stateless composables from stateful ones.
4. Use LazyColumn with per-item click callbacks without breaking recomposition.
5. Trigger side effects (Snackbar, coroutines) safely using rememberCoroutineScope.
6. Build a full MVVM screen with ViewModel, StateFlow, and sealed event classes.
7. Identify and fix common anti-patterns such as multiple sources of truth and recomposition traps.
Section A — Conceptual Foundation
A.1 Declarative vs Imperative UI
In traditional Android (imperative style) you retrieve a reference to a View and mutate it directly:
[Link] = "Clicked!".
The developer is responsible for keeping every widget in sync with the application data.
Jetpack Compose uses a declarative model: you describe what the UI should look like given a particular state and
Compose takes care of updating the screen when that state changes. This shifts where event handling logic lives,
instead of callbacks that mutate views, you write lambdas that mutate state, and the UI re-renders automatically.
Key Term: Declarative UI
A UI programming model where the developer declares the desired UI as a function of current
state: UI = f(state). When state changes, the framework re-evaluates the function and efficiently
updates only the parts of the UI that changed.
Aspect Imperative (View System) Declarative (Compose)
State Owner Views hold their own state Kotlin variables / ViewModel
Update Mechanism Directly mutate widget properties Change state → Compose recomposes
Event Callback setOnClickListener { ... } onClick = { ... } lambda
Risk Out-of-sync UI bugs Incorrect state placement
A.2 What Is an 'Event' in Compose?
An event is any user action or system notification that your app needs to respond to. In Compose, events are
modelled as lambda parameters on composable functions. Common examples include:
• onClick — user taps a Button or a clickable element
• onValueChange — user types in a TextField
• onCheckedChange — user toggles a Switch or Checkbox
• onDismissRequest — user dismisses a dialog
• Custom lambdas — e.g., onItemSelected: (Student) -> Unit passed to a list
Key Term: Event Lambda
A Kotlin lambda (anonymous function) passed as a parameter to a composable. When the
specified user gesture occurs, Compose invokes the lambda. The lambda should update state, not
manipulate UI directly.
A.3 Unidirectional Data Flow (UDF)
UDF is the architectural principle that governs how state and events travel in a Compose application:
• State flows DOWN — from parent composables (or ViewModel) to child composables as parameters.
• Events flow UP — children invoke lambdas to notify parents that something happened; the parent decides
how to react.
This makes data flow predictable and testable. A child composable never directly modifies shared state — it only
asks the parent to do so.
Key Term: Unidirectional Data Flow (UDF)
An architectural pattern where state and events travel in a single direction: state flows down the
composable tree as parameters, and user events flow up as lambda callbacks. This prevents
inconsistent state and makes recomposition predictable.
Visualise UDF like this:
┌──────────────────────────────────────────┐
│ ViewModel / Parent Composable │
│ val count by remember { mutableStateOf(0) }│
└──────┬───────────────────────┬───────────┘
│ state down │ event up
▼ │
┌──────────────────┐ onIncrement() called
│ CounterDisplay │──────────────────────▶
│ count = 3 │
└──────────────────┘
A.4 Recomposition
Recomposition is Compose's mechanism for updating the UI. When a state variable that a composable reads
changes, Compose re-executes (recomposes) that composable and any composables that depend on it.
What triggers recomposition: Any change to a MutableState<T> object that is read during the last composition
of a composable.
What does NOT trigger recomposition: Changing a plain Kotlin variable (val/var), or a MutableState that no
composable is currently reading.
Common Mistake — Plain var vs State
var count = 0 // Changing this does NOT recompose — Compose cannot observe it
var count by mutableStateOf(0) // Compose tracks reads and schedules recomposition
A.5 State Concepts
Compose provides several APIs for managing state:
Key Terms — State APIs
remember { ... } Survives recomposition but is lost on configuration change.
mutableStateOf(value) Creates an observable MutableState<T>; reads inside composables
are tracked.
rememberSaveable { ... } Like remember, but also survives process death (saves to Bundle).
derivedStateOf { ... } Creates a state that is computed from other state(s). Only recomposes
when the derived result actually changes.
collectAsState() Extension on Flow/StateFlow to collect emissions as Compose State.
// remember + mutableStateOf (most common pattern)
var name by remember { mutableStateOf("") }
// rememberSaveable — survives rotate / process death
var score by rememberSaveable { mutableStateOf(0) }
// derivedStateOf — avoids extra recompositions
val isValid by remember { derivedStateOf { [Link] >= 3 } }
// 'isValid' only changes when the boolean result flips, not on every keystroke
Debugging Tip — Recomposition Counts
Add a side-effect-free log inside a composable to see how often it recomposes:
val recomposeCount = remember { mutableStateOf(0) }
[Link]++
Text("Recomposed: ${[Link]} times")
If a composable recomposes unexpectedly often, check whether it reads state that changes too
frequently.
Checkpoint Questions — Section A
1. What is the fundamental difference between declarative and imperative UI?
2. In UDF, which direction does state flow? Which direction do events flow?
3. What is the difference between remember and rememberSaveable?
4. Give one reason why plain var cannot be used as Compose state.
5. When would you choose derivedStateOf over a plain mutableStateOf?
Section B - Basic Event Handling
In this section we build self-contained, runnable composables for the four most common event types. Each
subsection follows the pattern: concept → syntax → full example → try-this variations.
B.1 Button Click (onClick)
A Button in Material3 accepts an onClick lambda that is invoked when the user taps it. The lambda should update
state, never directly mutate the UI.
Syntax
Button(
onClick = { /* lambda — update state here */ },
modifier = Modifier,
enabled = true,
){
Text("Label")
}
Full Runnable Example - Counter
import [Link].material3.*
import [Link].*
import [Link]
import [Link]
import [Link].*
@Composable
fun CounterScreen() {
// 'count' is observed by Compose. Changing it schedules recomposition.
var count by remember { mutableStateOf(0) }
Surface(modifier = [Link](), color =
[Link]) {
Column(
modifier = [Link](),
verticalArrangement = [Link],
horizontalAlignment = [Link]
){
Text(
text = "Count: $count",
style = [Link]
)
Spacer(modifier = [Link]([Link]))
Row(horizontalArrangement = [Link]([Link])) {
Button(onClick = { count-- }) { Text("-") }
Button(onClick = { count++ }) { Text("+") }
// onClick lambda is captured by value at composition time.
// When count changes, Compose recomposes this Column.
}
}
}
}
What happens when you click '+': The onClick lambda runs, incrementing count. Compose detects the state
change, recomposes CounterScreen, and the Text displays the new value.
Try This
1. Add a 'Reset' Button that sets count = 0.
2. Disable the '-' button when count == 0 using the enabled parameter.
3. Change the Text colour to red when count is negative.
B.2 Text Input (TextField / onValueChange)
OutlinedTextField is the Material3 variant most commonly used in forms. It fires onValueChange every time the
text changes, providing the new string value.
Syntax
OutlinedTextField(
value = text, // current value from state
onValueChange = { newText ->
text = newText // update state with every keystroke
},
label = { Text("Name") },
singleLine = true
)
Full Runnable Example — Live Character Counter
import [Link].material3.*
import [Link].*
import [Link]
import [Link].*
@Composable
fun TextInputScreen() {
var username by remember { mutableStateOf("") }
val maxLength = 20
Column(modifier = [Link]([Link]), verticalArrangement =
[Link]([Link])) {
OutlinedTextField(
value = username,
onValueChange = { newValue ->
// Guard: only allow up to maxLength characters
if ([Link] <= maxLength) username = newValue
},
label = { Text("Username") },
placeholder = { Text("e.g. john_doe") },
supportingText = { Text("${[Link]} / $maxLength") },
isError = [Link] == maxLength,
singleLine = true,
modifier = [Link]()
)
Text(
text = if ([Link]()) "Hello, $username!" else "Type your name
above.",
style = [Link]
)
}
}
What happens when you type: Each keystroke fires onValueChange with the entire new string. The lambda updates
username state. Compose recomposes the OutlinedTextField (showing the new value) and the greeting Text.
Common Mistake — Forgetting to update state
onValueChange = { } // Empty! Text never changes — keyboard input appears to be
ignored.
onValueChange = { username = it } // Always update the state variable.
Compose does not automatically reflect typed text in a TextField — the single source of truth is
the state variable.
B.3 Switch and Checkbox Toggles
Both Switch and Checkbox follow the same pattern: you provide the current boolean value and an
onCheckedChange lambda that receives the new value.
import [Link].material3.*
import [Link].*
import [Link]
import [Link]
import [Link].*
@Composable
fun TogglesScreen() {
var switchOn by remember { mutableStateOf(false) }
var checked by remember { mutableStateOf(false) }
Column(modifier = [Link]([Link]), verticalArrangement =
[Link]([Link])) {
// ── Switch ──
Row(verticalAlignment = [Link],
horizontalArrangement = [Link]([Link])) {
Text("Dark mode")
Switch(
checked = switchOn,
onCheckedChange = { switchOn = it } // 'it' is the new boolean
)
}
// ── Checkbox ──
Row(verticalAlignment = [Link],
horizontalArrangement = [Link]([Link])) {
Checkbox(
checked = checked,
onCheckedChange = { checked = it }
)
Text("I agree to the Terms and Conditions")
}
Button(
onClick = { /* submit */ },
enabled = checked // button only active when terms accepted
) { Text("Continue") }
}
}
Try This
1. Add a TriStateCheckbox for a 'Select All' scenario with a list of checkboxes.
2. Wrap the Switch row in a Card and change the Card background based on switchOn.
B.4 [Link]
[Link] turns any composable into a tappable element — useful for custom list items, image cards, or
anywhere a Button's styling is unwanted.
import [Link]
import [Link].material3.*
import [Link].*
import [Link]
import [Link]
import [Link].*
@Composable
fun ClickableCardScreen() {
var selectedCard by remember { mutableStateOf(-1) }
val cards = listOf("Kotlin", "Compose", "MVVM")
Column(modifier = [Link]([Link]), verticalArrangement =
[Link]([Link])) {
[Link] { index, label ->
Card(
modifier = Modifier
.fillMaxWidth()
.clickable( // <-- makes the whole card clickable
onClickLabel = "Select $label", // accessibility
role = [Link]
){
selectedCard = index // update state on tap
},
colors = [Link](
containerColor = if (selectedCard == index)
[Link]
else
[Link]
)
){
Text(
text = label,
modifier = [Link]([Link]),
style = [Link]
)
}
}
}
}
Common Mistake — Nested clickables
Applying [Link] to both a parent Card and a child Button can cause gesture conflicts.
Compose propagates touch events up — the child typically consumes the event first. Design your
hierarchy so only one element handles a given tap.
Debugging Tip — Ripple not showing?
If you do not see a ripple on a clickable element, make sure it is wrapped in a MaterialTheme and
that the composable has a non-zero size. Also check that you are not disabling indication
accidentally with indication = null.
Checkpoint Questions — Section B
1. Why must the onClick lambda update state rather than directly change a widget's text?
2. What happens if you leave onValueChange empty in a TextField?
3. What parameter on Switch provides the current toggle value?
4. Name two differences between Button(onClick) and [Link].
5. What is onClickLabel used for in [Link]?
Section C — State Hoisting
C.1 Why Hoist State?
State hoisting is the practice of moving state out of a composable and into its caller. This produces stateless child
composables that are easier to test, preview, and reuse.
Ask yourself: 'Does this state need to be shared with a sibling or a parent?' If yes, hoist it. A child composable
should own only state that is entirely local to it and of no interest to anyone else.
C.2 Stateless vs Stateful Composables
Type Characteristics When to Use
Stateful Owns its own state with remember. Cannot be tested Top-level screens; temporary
without running on device/emulator. local UI state
Stateless Receives state and events as parameters. Easily unit- Reusable components; any
testable and previewable. shared state scenario
C.3 Hoist with Simple Callback — () -> Unit
When the child just needs to notify the parent that something happened, pass a zero-argument lambda.
import [Link].material3.*
import [Link].*
import [Link]
import [Link]
import [Link].*
// ── Stateless child — receives count and a callback
──────────────────────
@Composable
fun CounterWidget(
count: Int, // state flows DOWN as a parameter
onIncrease: () -> Unit, // event flows UP as a lambda
onDecrease: () -> Unit,
){
Row(verticalAlignment = [Link],
horizontalArrangement = [Link]([Link])) {
IconButton(onClick = onDecrease) { Text("-", style =
[Link]) }
Text(text = "$count", style = [Link])
IconButton(onClick = onIncrease) { Text("+", style =
[Link]) }
}
}
// ── Stateful parent — owns the state
─────────────────────────────────────
@Composable
fun CounterScreen() {
var count by remember { mutableStateOf(0) }
Column(modifier = [Link]([Link]),
horizontalAlignment = [Link]) {
Text("Score", style = [Link])
CounterWidget(
count = count,
onIncrease = { count++ }, // parent handles the event
onDecrease = { if (count > 0) count-- }
)
}
}
Notice that CounterWidget has no state of its own. You can easily preview it or unit-test it by passing arbitrary
values.
C.4 Hoist with Parameter Callback — (T) -> Unit
When the child needs to communicate a value back to the parent, add a parameter to the lambda. The most
common example is text input.
import [Link].material3.*
import [Link].*
import [Link]
import [Link].*
// ── Stateless reusable text field component
──────────────────────────────
@Composable
fun NameField(
value: String, // current text — state from parent
onNameChange: (String) -> Unit, // parent decides what to do with new value
modifier: Modifier = Modifier
){
OutlinedTextField(
value = value,
onValueChange = onNameChange, // forward the event up
label = { Text("Full Name") },
modifier = [Link]()
)
}
// ── Stateful screen
──────────────────────────────────────────────────────
@Composable
fun RegistrationScreen() {
var name by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
Column(modifier = [Link]([Link]), verticalArrangement =
[Link]([Link])) {
NameField(
value = name,
onNameChange = { name = [Link]() } // parent can transform the value
)
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
modifier = [Link]()
)
val isFormValid = [Link]() && [Link]("@")
Button(onClick = { /* submit */ }, enabled = isFormValid) {
Text("Register")
}
}
}
Common Mistake — Mutating state inside a stateless composable
If a composable stores state AND forwards events to a parent, you may end up with two sources of
truth — the local remember variable and the parent's state — which can diverge.
Rule: a composable should either own its state (stateful) OR receive it (stateless). Not both.
Checkpoint Questions — Section C
1. What is the formal definition of state hoisting?
2. When does a composable NOT need state hoisting?
3. What is the signature difference between a simple event callback and a parameterised one?
4. How does state hoisting make a composable easier to test?
Section D - Lists and Events
D.1 LazyColumn Item Clicks
LazyColumn renders only the visible items and recycles composables as you scroll. Each item's click callback must
be passed as a parameter — never hold per-item state inside the item composable.
D.2 State and Selection in Lists
A common mistake is storing selected state inside the list item composable. Because Compose can reuse item
composables as you scroll, local state inside items can appear on the wrong item. Always hold selection state in the
parent.
Common Mistake — State inside LazyColumn item
var isSelected by remember { mutableStateOf(false) } // inside item composable
// LazyColumn may reuse this slot for a different item as you scroll!
// The selection visual 'jumps' to a different item.
// Hold selection in the parent and pass it down:
var selectedId by remember { mutableStateOf<Int?>(null) }
isSelected = ([Link] == selectedId)
D.3 Student List App — Complete Example
import [Link]
import [Link].*
import [Link]
import [Link]
import [Link].material3.*
import [Link].*
import [Link]
import [Link]
import [Link]
// ── Data model
─────────────────────────────────────────────────────────
──
data class Student(val id: Int, val name: String, val grade: String)
val sampleStudents = listOf(
Student(1, "Alice Kamau", "A"),
Student(2, "Bob Otieno", "B+"),
Student(3, "Carol Njoroge","A-"),
Student(4, "David Waweru", "B"),
Student(5, "Eve Mutua", "A+"),
)
// ── Stateless list item
───────────────────────────────────────────────────
@Composable
fun StudentItem(
student: Student,
isSelected: Boolean,
onClick: () -> Unit, // event UP — no state here
){
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
colors = [Link](
containerColor = if (isSelected)
[Link]
else
[Link]
)
){
Row(
modifier = [Link]([Link]).fillMaxWidth(),
verticalAlignment = [Link],
horizontalArrangement = [Link]
){
Column {
Text(text = [Link], style = [Link])
Text(text = "ID: ${[Link]}", style = [Link])
}
Text(
text = [Link],
style = [Link],
color = [Link]
)
}
}
}
// ── Stateful screen
──────────────────────────────────────────────────────
@Composable
fun StudentListScreen() {
// selectedId lives in the PARENT — never inside StudentItem
var selectedId by remember { mutableStateOf<Int?>(null) }
Scaffold(topBar = {
TopAppBar(title = { Text("Student Register") })
}) { padding ->
LazyColumn(
contentPadding = PaddingValues([Link]),
verticalArrangement = [Link]([Link]),
modifier = [Link](padding)
){
items(sampleStudents, key = { [Link] }) { student ->
// key = { [Link] } helps Compose track items correctly during scroll
StudentItem(
student = student,
isSelected = ([Link] == selectedId),
onClick = {
selectedId = if (selectedId == [Link]) null else [Link]
}
)
}
}
// Show selected student detail below the list (optional)
selectedId?.let { id ->
val s = [Link] { [Link] == id }
// In a real app, navigate to a detail screen here
}
}
}
Debugging Tip — LazyColumn items jumping
If selected highlights appear on the wrong item after scrolling, you have state inside the item composable.
Move all selection/checked state to the parent and pass it as a parameter.
Also ensure you provide a stable key = { [Link] } to LazyColumn items — without it, Compose uses positional
identity which breaks during scroll.
Checkpoint Questions — Section D
1. Why is it dangerous to store selection state inside a LazyColumn item composable?
2. What does the key parameter in items() do?
3. In the StudentItem composable, why is isSelected passed as a parameter rather than computed internally?
Section E — Side Effects During Event Handling
E.1 Why Some Operations Require Coroutines
Some operations triggered by an event are not instant: showing a Snackbar, making a network call, writing to a
database. These involve asynchronous work and must not block the main thread. Compose provides coroutine-
based side-effect APIs for exactly this purpose.
E.2 rememberCoroutineScope
rememberCoroutineScope() returns a CoroutineScope tied to the composable's lifecycle. Launch coroutines from
event callbacks using this scope. The scope is cancelled automatically when the composable leaves the composition.
val scope = rememberCoroutineScope()
Button(onClick = {
[Link] {
// Safe to call suspend functions here
[Link]("Saved!")
}
}) { Text("Save") }
Key Term: rememberCoroutineScope
Returns a coroutine scope that is bound to the point in the composition where it is called. The
scope is cancelled when the composable leaves the composition, preventing memory leaks and
dangling coroutines.
E.3 LaunchedEffect vs Event Callbacks
API Trigger Typical Use Case
LaunchedEffect(key) Triggered when the composable One-time setup, navigation after
enters composition, or when key login, playing an animation on
changes appear
rememberCoroutineScope Triggered manually from an Showing Snackbar, async save on
event lambda (onClick, etc.) button press, debounce search
SideEffect After every successful Sync Compose state to non-
recomposition Compose framework
DisposableEffect Composable enters + leaves Register/unregister listeners,
composition lifecycle observers
Common Mistake — LaunchedEffect for button actions
LaunchedEffect runs automatically when state changes — not on demand. Using it for a button
action requires setting a trigger state variable, which is indirect and confusing.
Use rememberCoroutineScope + [Link] inside onClick for demand-triggered async
work.
Use LaunchedEffect for 'on screen appear' logic, such as auto-fetching data when a screen is
shown.
E.4 Scaffold + Snackbar Example
import [Link].material3.*
import [Link].*
import [Link]
import [Link].*
import [Link]
@Composable
fun SnackbarDemoScreen() {
// SnackbarHostState manages the Snackbar queue
val snackbarHostState = remember { SnackbarHostState() }
// Scope for launching coroutines from event callbacks
val scope = rememberCoroutineScope()
var inputText by remember { mutableStateOf("") }
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = { TopAppBar(title = { Text("Snackbar Demo") }) }
) { padding ->
Column(
modifier = [Link](padding).padding([Link]),
verticalArrangement = [Link]([Link])
){
OutlinedTextField(
value = inputText,
onValueChange = { inputText = it },
label = { Text("Task name") },
modifier = [Link]()
)
Button(
onClick = {
val taskName = [Link]()
if ([Link]()) {
// Launch coroutine to show snackbar — showSnackbar is a suspend
function
[Link] {
[Link](
message = "Task name cannot be empty",
withDismissAction = true
)
}
} else {
[Link] {
val result = [Link](
message = "Task '$taskName' added",
actionLabel = "Undo",
duration = [Link]
)
if (result == [Link]) {
// User tapped 'Undo' — reverse the action
inputText = taskName
}
}
inputText = ""
}
},
modifier = [Link]()
) { Text("Add Task") }
}
}
}
Checkpoint Questions — Section E
1. Why can you not call a suspend function directly inside an onClick lambda without a coroutine
scope?
2. What is the difference between rememberCoroutineScope and LaunchedEffect?
3. In the Snackbar example, what happens when the user taps the 'Undo' action label?
4. Why is it important that rememberCoroutineScope ties the scope to the composable lifecycle?
Section F — MVVM for Event Handling (Industry Standard)
F.1 MVVM Overview in Compose
Model-View-ViewModel (MVVM) separates concerns so that UI code (View/Composables) never performs
business logic directly. This makes the app testable, maintainable, and resilient to configuration changes.
Layer Compose Role Responsibilities
View @Composable functions Render state, collect user input, forward
events
ViewModel [Link] Hold and transform state, perform
subclass business logic, expose StateFlow
Model / Data classes, repositories, Room, Persist or fetch data; not aware of UI
Repository Retrofit
F.2 StateFlow + collectAsState
ViewModel exposes state as a StateFlow<UiState>. In the composable, you collect it with collectAsState(), which
subscribes and triggers recomposition whenever a new state is emitted.
// In ViewModel:
private val _uiState = MutableStateFlow(CounterUiState())
val uiState: StateFlow<CounterUiState> = _uiState.asStateFlow()
// In Composable:
val uiState by [Link]()
Text("Count: ${[Link]}")
F.3 Event Style 1 — Direct Methods ([Link]())
Each user action maps to a named method on the ViewModel. Simple and readable for small screens.
F.4 Complete MVVM Counter
import [Link]
import [Link]
import [Link]
import [Link]
import [Link]
// ── UiState data class
───────────────────────────────────────────────────
data class CounterUiState(val count: Int = 0)
// ── ViewModel
─────────────────────────────────────────────────────────
───
class CounterViewModel : ViewModel() {
private val _uiState = MutableStateFlow(CounterUiState())
val uiState: StateFlow<CounterUiState> = _uiState.asStateFlow()
fun increase() {
_uiState.update { [Link](count = [Link] + 1) }
}
fun decrease() {
_uiState.update { [Link](count = ([Link] - 1).coerceAtLeast(0)) }
}
fun reset() {
_uiState.update { CounterUiState() }
}
}
// ── Composable
─────────────────────────────────────────────────────────
──
import [Link]
import [Link]
@Composable
fun MvvmCounterScreen(vm: CounterViewModel = viewModel()) {
val state by [Link]()
Column(
modifier = [Link]().padding([Link]),
horizontalAlignment = [Link],
verticalArrangement = [Link]
){
Text(text = "Count: ${[Link]}", style = [Link])
Spacer([Link]([Link]))
Row(horizontalArrangement = [Link]([Link])) {
Button(onClick = { [Link]() }) { Text("-") }
Button(onClick = { [Link]() }) { Text("Reset") }
Button(onClick = { [Link]() }) { Text("+") }
}
}
}
F.5 Event Style 2 — Sealed Event Class ([Link](EventType)) [Preferred for Scalability]
As a screen grows, direct methods can become unwieldy. A sealed class models all possible events in one place and
routes them through a single onEvent function. This is the industry-preferred pattern for complex screens.
import [Link]
import [Link]
import [Link].*
import [Link]
// ── Events sealed class
───────────────────────────────────────────────────
sealed class LoginEvent {
data class UsernameChanged(val value: String) : LoginEvent()
data class PasswordChanged(val value: String) : LoginEvent()
object Submit : LoginEvent()
}
// ── UiState
─────────────────────────────────────────────────────────
──────
data class LoginUiState(
val username: String = "",
val password: String = "",
val isLoading: Boolean = false,
val errorMessage: String? = null,
val isLoggedIn: Boolean = false,
){
val isFormValid: Boolean
get() = [Link] >= 3 && [Link] >= 6
}
// ── ViewModel
─────────────────────────────────────────────────────────
───
class LoginViewModel : ViewModel() {
private val _state = MutableStateFlow(LoginUiState())
val state: StateFlow<LoginUiState> = _state.asStateFlow()
// Single entry-point for all UI events
fun onEvent(event: LoginEvent) {
when (event) {
is [Link] ->
_state.update { [Link](username = [Link], errorMessage = null) }
is [Link] ->
_state.update { [Link](password = [Link], errorMessage = null) }
is [Link] -> submitLogin()
}
}
private fun submitLogin() {
[Link] {
_state.update { [Link](isLoading = true) }
// Simulate network call
[Link](1500)
val s = _state.value
if ([Link] == "admin" && [Link] == "password") {
_state.update { [Link](isLoading = false, isLoggedIn = true) }
} else {
_state.update { [Link](isLoading = false, errorMessage = "Invalid credentials") }
}
}
}
}
// ── Composable
─────────────────────────────────────────────────────────
──
@Composable
fun LoginScreen(vm: LoginViewModel = viewModel()) {
val state by [Link]()
if ([Link]) {
// In a real app, trigger navigation here
Text("Welcome, ${[Link]}!", style = [Link],
modifier = [Link]([Link]))
return
}
Column(
modifier = [Link]().padding([Link]),
verticalArrangement = [Link],
horizontalAlignment = [Link]
){
Text("Login", style = [Link])
Spacer([Link]([Link]))
OutlinedTextField(
value = [Link],
onValueChange = { [Link]([Link](it)) },
label = { Text("Username") },
singleLine = true,
isError = [Link] != null,
modifier = [Link]()
)
Spacer([Link]([Link]))
OutlinedTextField(
value = [Link],
onValueChange = { [Link]([Link](it)) },
label = { Text("Password") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
isError = [Link] != null,
modifier = [Link]()
)
[Link]?.let {
Text(it, color = [Link],
style = [Link])
}
Spacer([Link]([Link]))
Button(
onClick = { [Link]([Link]) },
enabled = [Link] && ![Link],
modifier = [Link]()
){
if ([Link]) {
CircularProgressIndicator(modifier = [Link]([Link]), strokeWidth = [Link])
} else {
Text("Log In")
}
}
}
}
Why Sealed Events Scale Better
When a screen has 10+ actions, a sealed class keeps all events in a single discoverable file. New events are
added by creating a new subclass — no need to add new public methods to the ViewModel. Event history can
also be logged or replayed easily for debugging.
Checkpoint Questions — Section F
1. What is the role of the ViewModel in MVVM?
2. What does collectAsState() do?
3. What are two advantages of sealed event classes over direct ViewModel methods?
4. Why should the composable never contain business logic?
5. In the Login example, which layer decides whether credentials are valid?
Section G - Best Practices and Anti-Patterns
This section consolidates patterns to follow and pitfalls to avoid as you build production-grade Compose
applications.
G.1 Never Do Heavy Work in Composables
Composables may be called frequently (every recomposition). Expensive operations — file I/O, network calls, or
complex computation — must live in the ViewModel (using viewModelScope) or a repository. The composable
should only display data.
// ANTI-PATTERN — heavy work in composable
@Composable
fun BadScreen() {
val data = loadFromDatabase() // blocks composition, causes jank
Text(data)
}
// CORRECT — ViewModel does the work, composable observes
@Composable
fun GoodScreen(vm: MyViewModel = viewModel()) {
val data by [Link]()
Text(data ?: "Loading...")
}
G.2 Avoid Multiple Sources of Truth
If the same piece of data is stored in two different state variables, they can drift out of sync and produce hard -to-
diagnose bugs. Derive all secondary state from the single authoritative source.
// Two sources of truth
var email by remember { mutableStateOf("") }
var isEmailValid by remember { mutableStateOf(false) } // must remember to sync this!
// Single source, derived state
var email by remember { mutableStateOf("") }
val isEmailValid by remember { derivedStateOf { [Link]("@") && [Link](".")
}}
G.3 Avoid Recomposition Traps
Recomposition is cheap but not free. Minimise unnecessary work by: using keys in LazyColumn, using
derivedStateOf when deriving boolean/computed values, and avoiding creating new lambda instances with
captures that change on every recomposition.
// New lambda instance on every recomposition — defeats Compose's optimisation
LazyColumn {
items(students) { s ->
StudentItem(s, onClick = { doSomething([Link]) }) // new lambda each time
}
}
// Lambda is stable if it only captures stable values
// In practice, for small lists this is acceptable; for large lists extract callbacks
G.4 Debugging Event Issues
Problem Likely Cause Fix
Tapping a button onClick lambda empty or state not Add a log inside onClick; verify state
does nothing updated variable updates
TextField ignores onValueChange not updating state Ensure onValueChange = { text =
input it }
Recomposition loops State mutated during composition Never write to a MutableState at the
forever top level of a composable
Selected item jumps Selection state inside item Hoist selection state to the parent
in list composable
Snackbar never showSnackbar not in a coroutine Wrap in [Link] { }
shows
ViewModel state lost Using remember instead of Move state to ViewModel or use
on rotate ViewModel or rememberSaveable rememberSaveable
Lab Sheet - Practical Exercises
Lab Setup
1. Open Android Studio and create a new project using the Empty Activity template.
2. Minimum SDK: API 24 (Android 7.0). Target SDK: latest stable.
3. Ensure the following dependencies are present in [Link] (app):
implementation("[Link]:lifecycle-viewmodel-compose:2.7.0")
implementation("[Link].material3:material3")
implementation("[Link]:activity-compose:1.8.0")
implementation("[Link]:lifecycle-runtime-ktx:2.7.0")
4. Replace the contents of [Link] so setContent calls your composable under MaterialTheme.
Lab Tasks
Task 1 - Basic Counter (15 marks)
Difficulty:
Build a counter screen with the following requirements:
• Display the current count as a large number in the centre of the screen.
• '+' and '-' buttons below the count; '-' is disabled when count is 0.
• A 'Reset' button that returns count to 0.
• The count text changes colour: green for positive, grey for zero.
Expected UI: Three buttons in a Row, count above. Clicking '+' increments visibly.
Submit: Screenshot of the running app + [Link] file.
Task 2 - Registration Form (20 marks)
Difficulty:
Build a registration form with:
• Fields: Full Name, Email, Password (masked), Confirm Password.
• Validation (show error text below each field):
- Name: at least 3 characters
- Email: must contain '@' and '.'
- Password: at least 8 characters
- Confirm Password: must match Password
• 'Register' button enabled only when ALL fields pass validation.
• On successful registration, show a Snackbar: 'Account created for [name]!'.
Apply state hoisting: create a stateless RegistrationForm composable and a stateful
RegistrationScreen.
Submit: Screenshot + [Link] + [Link].
Task 3 - Selectable Course List (20 marks)
Difficulty:
Using LazyColumn, build a course selection screen:
• Display at least 8 courses (name + credit hours).
• Tapping a course toggles its selection (multiple selection allowed).
• Selected courses are highlighted with a different Card colour.
• A summary bar at the bottom shows: 'X courses selected, Y total credits'.
• A 'Clear All' button deselects everything.
IMPORTANT: Selection state must NOT be stored inside the list item composable.
Submit: Video/GIF of scrolling and selecting + [Link].
Task 4 - MVVM Counter with History (25 marks)
Difficulty:
Extend the counter from Task 1 using MVVM:
• CounterViewModel with StateFlow<CounterUiState>.
• UiState includes: count (Int), history (List<String>) — e.g., ["+1 → 1", "+1 → 2", ..., "Reset
→ 0"].
• Composable shows count, then a LazyColumn of history below it.
• Sealed event class: Increment, Decrement, Reset.
• History is cleared when Reset is pressed.
Demonstrate that count survives screen rotation (ViewModel handles this automatically).
Submit: [Link] + [Link] + screenshot before and after rotation.
Task 5 - MVVM Quiz App (20 marks)
Difficulty:
Build a simple multi-question quiz:
• At least 5 questions with 4 options each (hardcoded data class).
• Display one question at a time; show option buttons.
• Selecting an option immediately shows correct/incorrect feedback (highlight green/red).
• A 'Next' button advances to the next question (disabled until an option is chosen).
• After the last question, show a Result screen: 'You scored X / 5'.
• A 'Restart' button resets the quiz.
Architecture: QuizViewModel with sealed events (OptionSelected, NextQuestion, Restart).
Submit: All Kotlin files + short video demonstration.
Appendix - Quick-Reference Cheat Sheet
Task API / Pattern
Observe state + recompose var x by remember { mutableStateOf(v) }
Survive rotation rememberSaveable { mutableStateOf(v) }
Derived boolean state val valid by remember { derivedStateOf { ... } }
Button click Button(onClick = { state = newValue })
Text input OutlinedTextField(value=x, onValueChange={x=it})
Toggle Switch(checked=on, onCheckedChange={on=it})
Custom clickable area [Link] { state = ... }
Hoist state (no param) onSomething: () -> Unit
Hoist state (with param) onChange: (T) -> Unit
Async on button press val scope=rememberCoroutineScope(); [Link]{...}
Auto-run on appear LaunchedEffect(Unit) { ... }
ViewModel state MutableStateFlow + collectAsState()
All events in one place sealed class MyEvent; [Link](event)
List with stable identity items(list, key={[Link]}) { ... }