Chapter 6: State Management and Interactivity
State Management and Interactivity! This is the chapter where your app stops being static and starts feeling alive — buttons actually do things, text fields update in real-time, sliders change values instantly, and the UI reacts smoothly without you manually redrawing everything.
In Jetpack Compose (as of January 2026, we’re on Compose UI/Material3 ~1.10.1 stable, with 1.11 alphas rolling out), state management is the single most important skill after learning layouts. Done right, your app is fast, bug-free, and easy to maintain. Done wrong, you get flickering UIs, lost data on rotation, or performance nightmares.
We’re going to cover this like we’re pair-programming in Airoli: theory first, then code examples, common pitfalls (especially ones I see in beginner projects), and a full hands-on interactive app at the end.
1. Managing UI State: remember & mutableStateOf (The Basics)
State in Compose = any piece of data that can change and cause the UI to redraw (recompose).
- mutableStateOf → Creates an observable state holder.
- remember → Remembers that state across recompositions (so it doesn’t reset every time the composable runs).
Basic counter example (add this to a new Composable):
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @Composable fun Counter() { // DON'T do this → resets to 0 every recomposition! // var count = 0 // Correct: remember + mutableStateOf var count by remember { mutableIntStateOf(0) } // 'by' delegation = clean syntax Column { Text("Count: $count", fontSize = 32.sp) Button(onClick = { count++ }) { Text("Increment") } } } |
- var count by … → Delegation magic: get() reads state, set() triggers recomposition.
- Without remember: count resets to 0 every time parent recomposes (e.g., theme change).
- mutableIntStateOf, mutableDoubleStateOf, etc. → Specialized for primitives (faster, less allocation).
rememberSaveable → Survives configuration changes (rotation, process death):
|
0 1 2 3 4 5 6 |
var count by rememberSaveable { mutableIntStateOf(0) } |
Use rememberSaveable for things users expect to keep (like form input after rotating phone).
2. Handling User Input and Events
Compose is event-driven — pass lambdas for clicks, changes, etc.
TextField example (real form input):
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 |
var name by remember { mutableStateOf("") } OutlinedTextField( value = name, onValueChange = { newText -> name = newText }, label = { Text("Your Name") }, modifier = Modifier.fillMaxWidth() ) |
- onValueChange → Called on every keystroke.
- Two-way binding: UI shows value, user changes → onValueChange updates state → recomposition shows new value.
Slider + Checkbox example:
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
var tipPercent by remember { mutableIntStateOf(15) } var roundTip by remember { mutableStateOf(true) } Slider( value = tipPercent.toFloat(), onValueChange = { tipPercent = it.toInt() }, valueRange = 0f..30f ) Checkbox( checked = roundTip, onCheckedChange = { roundTip = it } ) |
Events → Update state → Recomposition → UI updates automatically. Unidirectional data flow!
3. ViewModel and State Hoisting (The Pro Way)
For anything beyond local UI state (shared across composables, survives navigation, survives rotation), hoist to ViewModel.
State Hoisting Rules (memorize these!):
- State lives in the lowest common parent of all readers/writers.
- Hoist to at least the highest place that needs to change it.
- Related states that change together → hoist together.
ViewModel → Lifecycle-aware, survives config changes/navigation.
Add dependency (if not there):
|
0 1 2 3 4 5 6 |
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6") // latest 2026 |
Example: CounterViewModel
|
0 1 2 3 4 5 6 7 8 9 10 11 |
class CounterViewModel : ViewModel() { private val _count = MutableStateFlow(0) val count: StateFlow<Int> = _count.asStateFlow() fun increment() { _count.value++ } } |
In Composable (hoist state):
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
@Composable fun CounterScreen(viewModel: CounterViewModel = viewModel()) { val count by viewModel.count.collectAsStateWithLifecycle() // Safe collect Column { Text("Count: $count") Button(onClick = { viewModel.increment() }) { Text("Add") } } } |
- viewModel() → Factory that survives config changes.
- collectAsStateWithLifecycle() → Best practice: pauses collection when not in foreground.
- For simple cases: Use mutableStateOf in ViewModel (but StateFlow is preferred for flows/coroutines).
UI State Pattern (2026 best practice): Use a sealed data class for screen state:
|
0 1 2 3 4 5 6 7 8 9 10 |
data class CounterUiState( val count: Int = 0, val isLoading: Boolean = false, val error: String? = null ) |
Then ViewModel exposes StateFlow<CounterUiState>, composable observes it.
4. Hands-on: Interactive App with Buttons and Forms (Enhanced Tip Calculator)
Let’s upgrade our previous Tip Calculator to be fully interactive in one screen first (local state), then hoist to ViewModel + multi-screen.
Single-Screen Interactive Version (add to your project):
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 |
@Composable fun InteractiveTipCalculator() { var billText by remember { mutableStateOf("") } var tipPercent by remember { mutableIntStateOf(15) } var roundTip by rememberSaveable { mutableStateOf(true) } var tipAmount by remember { mutableDoubleStateOf(0.0) } var total by remember { mutableDoubleStateOf(0.0) } Column( modifier = Modifier .fillMaxSize() .padding(24.dp) .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { Text("Tip Calculator", style = MaterialTheme.typography.headlineLarge) Spacer(Modifier.height(24.dp)) OutlinedTextField( value = billText, onValueChange = { billText = it }, label = { Text("Bill Amount (₹)") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), singleLine = true, modifier = Modifier.fillMaxWidth() ) Spacer(Modifier.height(16.dp)) Text("Tip: $tipPercent%", style = MaterialTheme.typography.titleMedium) Slider( value = tipPercent.toFloat(), onValueChange = { tipPercent = it.toInt() }, valueRange = 0f..30f, steps = 29, modifier = Modifier.fillMaxWidth() ) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { Checkbox(checked = roundTip, onCheckedChange = { roundTip = it }) Text("Round tip to nearest rupee") } Spacer(Modifier.height(32.dp)) Button( onClick = { val bill = billText.toDoubleOrNull() ?: 0.0 var tip = bill * (tipPercent / 100.0) if (roundTip) tip = tip.roundToInt().toDouble() tipAmount = tip total = bill + tip }, modifier = Modifier.fillMaxWidth() ) { Text("Calculate") } Spacer(Modifier.height(32.dp)) if (total > 0) { Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp)) { Text("Tip Amount: ₹${"%.2f".format(tipAmount)}", fontSize = 20.sp) Text("Total: ₹${"%.2f".format(total)}", fontSize = 28.sp, fontWeight = FontWeight.Bold) } } } } } |
Features:
- Real-time slider updates tip %.
- Calculate button triggers computation.
- rememberSaveable for roundTip (survives rotation!).
- Card for result (Material 3 style).
Next level: Hoist to ViewModel + add reset button + error message if bill invalid.
Create TipViewModel.kt:
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
class TipViewModel : ViewModel() { private val _uiState = MutableStateFlow(TipUiState()) val uiState: StateFlow<TipUiState> = _uiState.asStateFlow() fun updateBill(bill: String) { _uiState.update { it.copy(billText = bill) } } fun updateTipPercent(percent: Int) { _uiState.update { it.copy(tipPercent = percent) } } fun toggleRound(checked: Boolean) { _uiState.update { it.copy(roundTip = checked) } } fun calculate() { val bill = _uiState.value.billText.toDoubleOrNull() ?: 0.0 if (bill <= 0) { _uiState.update { it.copy(error = "Enter valid bill amount") } return } var tip = bill * (_uiState.value.tipPercent / 100.0) if (_uiState.value.roundTip) tip = tip.roundToInt().toDouble() _uiState.update { it.copy( tipAmount = tip, total = bill + tip, error = null ) } } fun reset() { _uiState.update { TipUiState() } } } data class TipUiState( val billText: String = "", val tipPercent: Int = 15, val roundTip: Boolean = true, val tipAmount: Double = 0.0, val total: Double = 0.0, val error: String? = null ) |
Then Composable:
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
@Composable fun TipCalculatorScreen(viewModel: TipViewModel = viewModel()) { val state by viewModel.uiState.collectAsStateWithLifecycle() // ... same Column layout, but use state.billText, state.tipPercent, etc. // onValueChange = { viewModel.updateBill(it) } // Button calculate = { viewModel.calculate() } // Show state.error as Text(color = Color.Red) if not null } |
This is clean, testable, survives everything.
You’ve now mastered state! Play with it: Add a reset button, show live tip preview (calculate on slider change), or add dark mode toggle.
Questions? Want to add coroutines for fake API call? Navigation integration? Or fix any bug? Tell me — next chapter: Data persistence (Room/DataStore). You’re becoming a real Android pro — keep experimenting! 🚀💻
