Bobby Encoded
PostsAbout
PostsAbout

© 2026 Bobby Jose

← Back to Blog

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?

AspectrememberViewModel
Survives recompositionYesYes
Survives config changeNo (unless rememberSaveable)Yes
Survives process deathNoWith SavedStateHandle
Shared across composablesNoYes
Use caseUI 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

ToolSurvives RecompositionSurvives Config ChangeUse Case
rememberYesNoLocal UI state
rememberSaveableYesYesUser input
ViewModel + StateFlowYesYesBusiness state
derivedStateOfYesNoExpensive computations
ChannelN/AN/AOne-time events

Next in series: Part 3 - Navigation & Architecture covers Navigation Compose and Hilt dependency injection.

← Previous

Android Navigation and Architecture Patterns

Next →

Mastering Jetpack Compose Foundations