Android State Management Deep Dive
October 3, 2025 · 7 min read
Android, Kotlin, Jetpack Compose, State Management, Interview Prep
Part 2 of the Android Deep Dive series
State management is fundamental to Compose and one of the most heavily tested areas in Android interviews. Understanding when to use remember, mutableStateOf, ViewModel, and StateFlow distinguishes senior developers from juniors.
Why State Management Matters
In 2025-2026, interviewers expect candidates to understand the "single source of truth" principle and reactive UI patterns. With StateFlow vs LiveData debates settled (StateFlow wins for new projects), the focus has shifted to architecture decisions: when to hoist state, how to handle one-time events, and proper lifecycle management.
2025-2026 Trend
Interviewers now ask about collectAsStateWithLifecycle over collectAsState. Understanding why lifecycle-aware collection matters shows production experience.
Core Concepts
Local State with remember
@Composable
fun Counter() {
// State survives recomposition
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Count: $count")
}
}
// remember with key - resets when key changes
@Composable
fun UserGreeting(userId: String) {
// Resets when userId changes
var expanded by remember(userId) { mutableStateOf(false) }
}
State Hoisting Pattern
Move state up to make composables reusable and testable:
// Stateful - owns its state (hard to test/reuse)
@Composable
fun StatefulMealForm() {
var name by remember { mutableStateOf("") }
var calories by remember { mutableStateOf("") }
Column {
TextField(value = name, onValueChange = { name = it })
TextField(value = calories, onValueChange = { calories = it })
}
}
// Stateless - state hoisted to caller (reusable/testable)
@Composable
fun MealForm(
name: String,
onNameChange: (String) -> Unit,
calories: String,
onCaloriesChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier) {
TextField(value = name, onValueChange = onNameChange)
TextField(value = calories, onValueChange = onCaloriesChange)
}
}
Interview Tip
"Flow down state, flow up events" is the core principle. State flows down from parent to child; events flow up via callbacks.
ViewModel with StateFlow
// UI State sealed class
sealed interface MealsUiState {
data object Loading : MealsUiState
data class Success(val meals: List<Meal>) : MealsUiState
data class Error(val message: String) : MealsUiState
}
// ViewModel
@HiltViewModel
class MealsViewModel @Inject constructor(
private val mealRepository: MealRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<MealsUiState>(MealsUiState.Loading)
val uiState: StateFlow<MealsUiState> = _uiState.asStateFlow()
private val _selectedDate = MutableStateFlow(LocalDate.now())
val selectedDate: StateFlow<LocalDate> = _selectedDate.asStateFlow()
init {
loadMeals()
}
fun loadMeals() {
viewModelScope.launch {
_uiState.value = MealsUiState.Loading
try {
val meals = mealRepository.getMeals(_selectedDate.value)
_uiState.value = MealsUiState.Success(meals)
} catch (e: Exception) {
_uiState.value = MealsUiState.Error(e.message ?: "Unknown error")
}
}
}
fun setDate(date: LocalDate) {
_selectedDate.value = date
loadMeals()
}
}
Consuming StateFlow in Compose
@Composable
fun MealsScreen(
viewModel: MealsViewModel = hiltViewModel()
) {
// Lifecycle-aware collection - CRITICAL
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val selectedDate by viewModel.selectedDate.collectAsStateWithLifecycle()
Column {
DatePicker(
selected = selectedDate,
onDateSelected = viewModel::setDate
)
when (val state = uiState) {
is MealsUiState.Loading -> LoadingIndicator()
is MealsUiState.Success -> MealList(
meals = state.meals,
onDelete = viewModel::deleteMeal
)
is MealsUiState.Error -> ErrorMessage(state.message)
}
}
}
Common Mistake
Using collectAsState() instead of collectAsStateWithLifecycle() continues collection when the app is backgrounded, wasting resources and potentially causing crashes.
Combining Multiple StateFlows
@HiltViewModel
class DashboardViewModel @Inject constructor(
private val mealRepository: MealRepository,
private val userPreferences: UserPreferencesRepository
) : ViewModel() {
private val _selectedDate = MutableStateFlow(LocalDate.now())
// Combine multiple flows into UI state
val uiState: StateFlow<DashboardUiState> = combine(
_selectedDate,
userPreferences.nutritionGoals,
mealRepository.observeMeals()
) { date, goals, allMeals ->
val todaysMeals = allMeals.filter {
it.loggedAt.toLocalDate() == date
}
val totals = NutritionTotals(
calories = todaysMeals.sumOf { it.calories },
protein = todaysMeals.sumOf { it.protein },
carbs = todaysMeals.sumOf { it.carbs },
fat = todaysMeals.sumOf { it.fat }
)
DashboardUiState(
date = date,
meals = todaysMeals,
totals = totals,
goals = goals
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = DashboardUiState()
)
}
Derived State
@Composable
fun MealListWithSearch(meals: List<Meal>) {
var searchQuery by remember { mutableStateOf("") }
// Derived state - only recalculates when dependencies change
val filteredMeals by remember(meals, searchQuery) {
derivedStateOf {
if (searchQuery.isBlank()) meals
else meals.filter {
it.name.contains(searchQuery, ignoreCase = true)
}
}
}
Column {
TextField(
value = searchQuery,
onValueChange = { searchQuery = it },
label = { Text("Search meals") }
)
LazyColumn {
items(filteredMeals, key = { it.id }) { meal ->
MealCard(meal)
}
}
}
}
Side Effects
@Composable
fun MealDetailScreen(
mealId: String,
viewModel: MealDetailViewModel = hiltViewModel()
) {
// LaunchedEffect - runs when key changes
LaunchedEffect(mealId) {
viewModel.loadMeal(mealId)
}
// DisposableEffect - cleanup when leaving composition
DisposableEffect(Unit) {
val listener = viewModel.registerListener()
onDispose {
listener.unregister()
}
}
// SideEffect - runs after every successful recomposition
val analytics = LocalAnalytics.current
SideEffect {
analytics.logScreenView("MealDetail")
}
}
// rememberCoroutineScope for event handlers
@Composable
fun SaveMealButton(onSave: suspend () -> Unit) {
val scope = rememberCoroutineScope()
Button(
onClick = {
scope.launch {
onSave()
}
}
) {
Text("Save")
}
}
Interview Questions
Q: What's the difference between remember and ViewModel for state?
| Aspect | remember | ViewModel |
|---|---|---|
| Survives recomposition | Yes | Yes |
| Survives config change | No (unless rememberSaveable) | Yes |
| Survives process death | No | With SavedStateHandle |
| Shared across composables | No | Yes |
| Use case | UI state (expanded/collapsed) | Business logic, repository data |
// remember - ephemeral UI state
var isExpanded by remember { mutableStateOf(false) }
// ViewModel - persisted business state
class MealsViewModel : ViewModel() {
val meals: StateFlow<List<Meal>> = repository.observeMeals()
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
}
Q: What is collectAsStateWithLifecycle and why use it?
It collects a Flow into Compose State while respecting the lifecycle:
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Benefits:
- Stops collection when lifecycle is below
STARTED - Prevents memory leaks and wasted resources
- Automatically handles Activity/Fragment lifecycle
- Better than plain
collectAsState()for most cases
Q: Explain derivedStateOf and when to use it.
derivedStateOf creates state derived from other state with smart caching:
val filteredList by remember(items, query) {
derivedStateOf {
items.filter { it.contains(query) }
}
}
Use when:
- Computation is expensive
- Result changes less often than reads
- Multiple composables read the derived value
Don't use when computation is trivial.
Q: How do you handle one-time events (navigation, snackbar)?
// Channel + LaunchedEffect
class ViewModel : ViewModel() {
private val _events = Channel<Event>()
val events = _events.receiveAsFlow()
fun onAction() {
viewModelScope.launch {
_events.send(Event.NavigateToDetail)
}
}
}
@Composable
fun Screen(viewModel: ViewModel, onNavigate: () -> Unit) {
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
Event.NavigateToDetail -> onNavigate()
}
}
}
}
Common Mistakes
1. Creating State in Loop/Condition
// BAD - New state on every recomposition
@Composable
fun BadExample(items: List<Item>) {
Column {
items.forEach { item ->
var expanded by remember { mutableStateOf(false) }
// State tied to wrong position!
}
}
}
// GOOD - Use key
@Composable
fun GoodExample(items: List<Item>) {
Column {
items.forEach { item ->
key(item.id) {
var expanded by remember { mutableStateOf(false) }
}
}
}
}
2. Not Using Lifecycle-Aware Collection
// BAD - Continues collecting when app backgrounded
val state by viewModel.state.collectAsState()
// GOOD - Respects lifecycle
val state by viewModel.state.collectAsStateWithLifecycle()
3. ViewModel in Preview
// BAD - Breaks preview
@Composable
fun MealsScreen(viewModel: MealsViewModel = hiltViewModel()) {
// Can't preview - needs Hilt
}
// GOOD - Separate state and UI
@Composable
fun MealsScreen(viewModel: MealsViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
MealsContent(state = state) // Stateless, previewable
}
@Preview
@Composable
fun MealsContentPreview() {
MealsContent(state = MealsUiState.Success(sampleMeals))
}
4. StateFlow Not Updating UI
// BAD - Same object reference, StateFlow won't emit
_uiState.value.meals.add(newMeal) // Mutating same list!
// GOOD - New object (immutable update)
_uiState.update { current ->
current.copy(meals = current.meals + newMeal)
}
Summary Table
| Tool | Survives Recomposition | Survives Config Change | Use Case |
|---|---|---|---|
remember | Yes | No | Local UI state |
rememberSaveable | Yes | Yes | User input |
| ViewModel + StateFlow | Yes | Yes | Business state |
derivedStateOf | Yes | No | Expensive computations |
| Channel | N/A | N/A | One-time events |
Next in series: Part 3 - Navigation & Architecture covers Navigation Compose and Hilt dependency injection.