[CODE]

Managing State in Jetpack Compose with StateFlow and MVVM

After learning the basics of Android development, StateFlow + ViewModel clicked as the right pattern. Here's how I think about unidirectional data flow in Compose and why it makes things easier.

4 min read
android kotlin jetpack-compose stateflow

When I started building TackleBox — and thinking ahead to CastLoop_Android, which is still in early setup — I spent a good chunk of time just figuring out where to put state. Compose is great, but it doesn’t tell you how to structure your app — it just renders what you give it. After some trial and error, StateFlow + ViewModel became my go-to pattern. Here’s what clicked for me.

The Basic Shape

The pattern I landed on looks like this: ViewModel holds a StateFlow<UiState>, and the Composable collects it.

data class TackleUiState(
    val isLoading: Boolean = false,
    val items: List<TackleItem> = emptyList(),
    val error: String? = null
)

class TackleViewModel(private val repo: TackleRepository) : ViewModel() {
    private val _uiState = MutableStateFlow(TackleUiState())
    val uiState: StateFlow<TackleUiState> = _uiState.asStateFlow()

    init {
        loadItems()
    }

    private fun loadItems() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            repo.getItems()
                .catch { e -> _uiState.update { it.copy(isLoading = false, error = e.message) } }
                .collect { items ->
                    _uiState.update { TackleUiState(items = items) }
                }
        }
    }
}

A few things I want to call out here. The MutableStateFlow is private — you only expose StateFlow. This matters because if you leak the mutable version, any piece of code can mutate your state, and then you’ve lost the whole point of having a ViewModel manage it. Easy mistake to make early on.

The UiState as a data class with isLoading, error, and data fields also prevents the half-baked state problem. Before I had this pattern, I’d sometimes update items but forget to clear isLoading, and the UI would show a spinner forever. With a single state object, you replace the whole thing at once.

Collecting State in Compose

On the Composable side:

@Composable
fun TackleScreen(viewModel: TackleViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    when {
        uiState.isLoading -> CircularProgressIndicator()
        uiState.error != null -> ErrorMessage(uiState.error!!)
        else -> TackleList(uiState.items)
    }
}

The recommended approach here is collectAsStateWithLifecycle() rather than just collectAsState(). The lifecycle-aware version pauses collection when the app goes to the background, so you’re not doing unnecessary work (and draining battery) when nobody’s looking at the screen. I only noticed this distinction after reading the docs more carefully — the names are similar enough to miss.

That said, TackleBox currently uses plain collectAsState() since I haven’t pulled in the lifecycle-runtime-compose dependency yet. Migrating to collectAsStateWithLifecycle() is on the list once that dependency is in — it’s a small change that makes the behavior more correct.

User Events Stay Simple

For user interactions, I just expose functions on the ViewModel directly. No event channels, no sealed event classes — at least for straightforward cases.

// In ViewModel
fun deleteItem(id: String) {
    viewModelScope.launch {
        repo.delete(id)
    }
}

// In Composable
TackleItem(
    item = item,
    onDelete = { viewModel.deleteItem(item.id) }
)

Some tutorials show a UiEvent sealed class that flows through a Channel. That’s useful when you need one-off effects like showing a Snackbar or navigating away — things that shouldn’t be part of persistent UI state. But for most data operations, the direct function call is fine and easier to follow.

Room Integration

When I hooked up Room, the DAO returning a Flow<List<Item>> fit naturally into this:

// DAO
@Query("SELECT * FROM tackle_items ORDER BY name ASC")
fun observeItems(): Flow<List<TackleItemEntity>>

// ViewModel collects and maps
repo.observeItems()
    .map { entities -> entities.map { it.toDomain() } }
    .collect { items -> _uiState.update { it.copy(items = items, isLoading = false) } }

Room emits a new list every time the database changes, so the UI just stays in sync automatically. No manual refresh needed. That was one of those moments where things just worked the way I expected them to.

StateFlow vs LiveData

I started with LiveData because most older tutorials use it. Eventually I switched to StateFlow and didn’t look back. The main reasons:

  • StateFlow requires an initial value, so you’re never dealing with null on first collection
  • It’s plain Kotlin — no Android imports in the ViewModel, which makes testing simpler
  • It plays well with coroutine operators like map, combine, and filter
  • LiveData needs to be observed from a lifecycle owner; StateFlow doesn’t have that dependency in the ViewModel layer

The migration isn’t hard either — mostly swapping types and removing MutableLiveData in favor of MutableStateFlow.

The Mistake I Made Most

Putting logic in the Composable. It’s tempting when you’re moving fast — just throw a filter call right in the @Composable function. But the ViewModel is exactly where that belongs, and keeping Composables as dumb as possible makes them easier to reuse and preview.

If a Composable knows about your business rules, something’s off.