DEV Community

Cover image for Taming Complex State in Jetpack Compose: ViewModel + StateFlow in a Real-World Android Screen
WelcomeLegend-Git
WelcomeLegend-Git

Posted on

Taming Complex State in Jetpack Compose: ViewModel + StateFlow in a Real-World Android Screen

Introduction

If you have spent any meaningful time building screens in Jetpack Compose, you have probably hit the same wall. Your composable starts small, but features pile up. A search bar here, a loading spinner there, a toggle between modes, a progress tracker. Before you know it, state variables are scattered across half a dozen remember calls, recompositions fire when they shouldn't, and debugging feels like untangling holiday lights. The pain gets worse the moment your UI needs to survive a configuration change like a screen rotation.

The solution is a pattern the Android community calls Unidirectional Data Flow (UDF). Instead of sprinkling state throughout your composables, you consolidate everything into a single, immutable data class, expose it through a StateFlow inside a ViewModel, and let your composable observe it. State flows down to the UI. Events flow up to the ViewModel. The result is a screen that is predictable, testable, and resistant to unnecessary recompositions.

In this article, you will walk through a production Android screen: a map-based routing interface. You will dissect exactly how its state is modeled, updated, and consumed. You will see actual Kotlin code, not contrived counter examples, and learn why each architectural choice prevents bugs that surface in real apps.


Prerequisites

Before jumping in, make sure you are comfortable with the following:

  • Kotlin fundamentals: data classes, sealed classes, lambdas, and extension functions.
  • Jetpack Compose basics: you know what a @Composable function is and have rendered at least a Text() or Button() on screen.
  • Android project structure: you can navigate an app/src/main/java/ directory and understand what a ViewModel is at a high level.
  • Coroutines 101: you have seen launch, delay, and Flow before, even if you haven't mastered them.

You do not need prior experience with StateFlow, collectAsState, or the UDF pattern. That is exactly what this article covers.


Step 1: Define a Single Source of Truth with a UI State Data Class

The first and most impactful decision you can make is to model your entire screen's state as one immutable data class. Take a look at the one from the map screen:

enum class SearchField {
    FROM, TO
}

enum class RoutingProfile {
    CAR, BIKE, FOOT, TRAIN
}

data class MapUiState(
    val startPoint: GeoPoint? = null,
    val endPoint: GeoPoint? = null,
    val routePoints: List<GeoPoint> = emptyList(),
    val speedKmh: Float = 50f,
    val speedRange: ClosedRange<Float> = 10f..130f,
    val routingProfile: RoutingProfile = RoutingProfile.CAR,
    val isLoadingRoute: Boolean = false,
    val isSpoofing: Boolean = false,
    val currentPosition: GeoPoint? = null,
    val progress: Float = 0f,
    val errorMessage: String? = null,
    val routeDistanceKm: Double = 0.0,
    val routeDurationMin: Double = 0.0,
    val fromQuery: String = "",
    val toQuery: String = "",
    val activeSearchField: SearchField? = null,
    val searchResults: List<PlaceResult> = emptyList(),
    val isSearching: Boolean = false
)
Enter fullscreen mode Exit fullscreen mode

Why this matters

  • Immutability. Every field uses val. You never mutate state in place. You create a new copy. This makes it trivial for Compose's snapshot system to detect changes and recompose only the composables that actually read the changed field.
  • Default values. Every parameter has a sensible default, so you can construct an initial state with zero arguments: MapUiState(). This is great for previews, tests, and the initial screen load.
  • Exhaustiveness. If a piece of information drives something visible on screen, it belongs in this class. Whether a button is enabled, whether a loading spinner appears, whether an error banner shows. There is no hidden state lurking in a random var somewhere.

Think of MapUiState as a photograph of your screen at any given instant. If you serialized it to JSON and handed it to another developer, they should be able to reconstruct exactly what the user sees.


Step 2: Expose State via ViewModel and StateFlow

With your state class defined, the next step is to wire it into a ViewModel that survives configuration changes and exposes the state as a StateFlow:

class MapViewModel : ViewModel() {

    private val _uiState = MutableStateFlow(MapUiState())
    val uiState: StateFlow<MapUiState> = _uiState.asStateFlow()

    // ...
}
Enter fullscreen mode Exit fullscreen mode

Three lines, but a lot is happening here:

Element Role
_uiState The private, mutable backing flow. Only the ViewModel can write to it.
uiState The public, read-only projection. The composable can observe it, but never modify it directly.
asStateFlow() Prevents external callers from casting back to MutableStateFlow and bypassing the encapsulation.

This is the write boundary of UDF. Events enter the ViewModel through public functions. State exits through uiState. No back doors.


Step 3: Update State Atomically with update {}

When the user interacts with the screen, the ViewModel handles it through dedicated public functions. Tapping a map point, sliding a speed control, typing a search query: each one gets its own method. For example, selecting a start point looks like this:

fun setStartPoint(point: GeoPoint) {
    _uiState.update {
        it.copy(
            startPoint = point,
            fromQuery = "Map Location (%.5f, %.5f)".format(point.latitude, point.longitude),
            routePoints = emptyList(),
            errorMessage = null
        )
    }
    tryFetchRoute()
}
Enter fullscreen mode Exit fullscreen mode

The update {} lambda receives the current state as it and returns a new copy. This is atomic. If two coroutines call update concurrently, StateFlow guarantees that each one reads the latest snapshot before applying its transformation. No lost writes, no race conditions.

Notice how copy() only overrides the fields that change. The other 14+ fields carry forward untouched. This is the Kotlin data class pattern doing heavy lifting: you get granular updates without manual bookkeeping.

Coordinated state changes

Now look at setRoutingProfile(). When the user switches from "Car" to "Train," the speed, speed range, and route all need to change together:

fun setRoutingProfile(profile: RoutingProfile) {
    val (defaultSpeed, range) = when (profile) {
        RoutingProfile.FOOT  -> Pair(5f, 1f..15f)
        RoutingProfile.BIKE  -> Pair(15f, 2f..40f)
        RoutingProfile.CAR   -> Pair(60f, 10f..140f)
        RoutingProfile.TRAIN -> Pair(120f, 20f..250f)
    }
    _uiState.update {
        it.copy(
            routingProfile = profile,
            speedKmh = defaultSpeed,
            speedRange = range,
            routePoints = emptyList()
        )
    }
    tryFetchRoute()
}
Enter fullscreen mode Exit fullscreen mode

Because all four fields update inside a single update {} call, the composable receives one new state emission. If you had updated them one-by-one with four separate _uiState.value = ... calls, Compose could recompose after each intermediate state, producing a visible flicker. Batching is not just clean. It is correct.


Step 4: Handle Async Work with Debouncing and Coroutines

Search is where state management gets interesting. Users type quickly, and you do not want to fire a network request on every keystroke. Take a look at the debounced search implementation:

private var searchJob: Job? = null

fun onSearchQueryChanged(query: String, field: SearchField) {
    _uiState.update {
        if (field == SearchField.FROM) {
            it.copy(fromQuery = query, activeSearchField = field, isSearching = true)
        } else {
            it.copy(toQuery = query, activeSearchField = field, isSearching = true)
        }
    }

    searchJob?.cancel()
    if (query.isBlank()) {
        _uiState.update { it.copy(searchResults = emptyList(), isSearching = false) }
        return
    }

    searchJob = viewModelScope.launch {
        delay(500) // debounce
        try {
            val results = RetrofitProvider.nominatimApi.searchPlaces(query)
            _uiState.update { it.copy(searchResults = results, isSearching = false) }
        } catch (e: Exception) {
            _uiState.update { it.copy(searchResults = emptyList(), isSearching = false) }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

There are four things happening here, and each one matters:

  1. Immediate UI feedback. The query text and isSearching = true are applied instantly, so the text field updates without lag and a loading indicator can appear.
  2. Job cancellation. Each new keystroke cancels the previous coroutine via searchJob?.cancel(). If the user types "New Delhi" in 400ms, only the final query fires.
  3. 500ms debounce. The delay(500) call suspends the coroutine. If it gets cancelled before the delay completes, the network call never happens. This is structured concurrency doing exactly what it was designed to do.
  4. Error resilience. A failed network call clears the results and stops the loading indicator. The screen never gets stuck in a loading state.

Pay attention to the isSearching flag. It lives in the UI state and drives a CircularProgressIndicator in the composable. Because it is part of MapUiState, there is no separate var isLoading by remember { ... } floating in the composable. Everything flows through the same pipe.


Step 5: Observe State in the Composable with collectAsStateWithLifecycle

On the UI side, the composable needs exactly one line to start observing the ViewModel:

@Composable
fun MapScreen(
    modifier: Modifier = Modifier,
    viewModel: MapViewModel = viewModel()
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    // The rest of the UI reads from `uiState`
}
Enter fullscreen mode Exit fullscreen mode

collectAsStateWithLifecycle() is the lifecycle-aware version of collectAsState(). It automatically stops collecting when the composable is not visible, for example when the app moves to the background. This prevents unnecessary work and potential memory leaks. Always prefer it over the plain collectAsState().

From this point on, every read of uiState.someField in the composable tree is automatically tracked by Compose. When the ViewModel emits a new MapUiState, Compose diffs the old and new values and recomposes only the composables that read the changed fields.

Conditional UI driven by state

The composable uses state fields to toggle entire sections of the UI:

// Show spoofing progress bar ONLY when active
AnimatedVisibility(
    visible = uiState.isSpoofing,
    enter = slideInVertically() + fadeIn(),
    exit = slideOutVertically() + fadeOut(),
    modifier = Modifier.align(Alignment.TopCenter)
) {
    // Progress card UI...
    LinearProgressIndicator(
        progress = { uiState.progress },
        // ...
    )
    Text(
        "${(uiState.progress * 100).toInt()}% complete • ${uiState.speedKmh.toInt()} km/h",
        // ...
    )
}
Enter fullscreen mode Exit fullscreen mode

When isSpoofing flips from false to true, the progress card slides in with an animation. When it flips back, it slides out. The composable has zero logic about when to show or hide this section. It just reads the boolean. The ViewModel owns the decision.

Sending events back up

User interactions go to the ViewModel through simple function calls:

Slider(
    value = uiState.speedKmh,
    onValueChange = { viewModel.setSpeed(it) },
    valueRange = uiState.speedRange.start..uiState.speedRange.endInclusive,
    enabled = !uiState.isSpoofing,
    // ...
)
Enter fullscreen mode Exit fullscreen mode

The Slider reads its current value from uiState.speedKmh and reports changes via viewModel.setSpeed(it). The ViewModel updates the StateFlow, and the Slider recomposes with the new value. This is the UDF loop in action: state down, events up.


Step 6: Use Side Effects for Work Outside the Compose Lifecycle

Some operations, like polling a background service for progress updates, do not fit neatly into the recomposition model. That is where LaunchedEffect comes in:

LaunchedEffect(uiState.isSpoofing) {
    while (uiState.isSpoofing || SpoofingService.isRunning) {
        viewModel.updateSpoofingProgress()
        delay(1000L)
    }
    viewModel.updateSpoofingProgress() // one final update
}
Enter fullscreen mode Exit fullscreen mode

LaunchedEffect launches a coroutine scoped to the composable's lifecycle. The key you pass to it (uiState.isSpoofing) controls restarts: when that boolean changes, the previous coroutine is cancelled and a new one launches. Polling starts when the operation begins and stops when it ends. No manual lifecycle management required.


How This Architecture Prevents Common Pitfalls

Here is a quick summary of what this pattern protects you from:

Problem How UDF + StateFlow Solves It
State lost on rotation ViewModel survives configuration changes. StateFlow re-emits the latest value to new subscribers.
Unnecessary recompositions Compose's structural equality check on the data class skips recomposition when nothing actually changed.
Race conditions StateFlow.update {} is atomic and thread-safe.
Flickering UI from intermediate states Batching multiple field changes into a single copy() call emits one state snapshot, not several.
Stale state after async work The update {} lambda always reads the current state, not a captured snapshot from earlier.

Conclusion

Managing complex state in Jetpack Compose does not have to be painful. By consolidating your screen's state into a single immutable data class, exposing it through a StateFlow in a ViewModel, and letting your composable observe it with collectAsStateWithLifecycle(), you get a system that is predictable, testable, and efficient. State flows down, events flow up, and Compose handles the rest.

In this article, you walked through a real-world map screen that manages search queries with debouncing, routing profiles with coordinated multi-field updates, real-time progress polling via LaunchedEffect, and animated UI transitions driven entirely by state flags. These are the same patterns you will reach for in your own production screens, whether you are building a map, a checkout flow, or a social feed. Start with the data class, wire up the StateFlow, and let Unidirectional Data Flow keep your composables clean.


Code snippets in this article are extracted from an open-source Android project built with Kotlin, Jetpack Compose, and Material 3.

Top comments (0)