# How to Manage UI State Using StateFlow and ViewModel in Jetpack Compose

> Learn how to manage UI state in Jetpack Compose with StateFlow and ViewModel. GSY GitHub App uses unidirectional data flow and StateFlow for robust state management, loading, and error handling.

- Repository: [Shuyu Guo/gsygithubappcompose](https://github.com/carguo/gsygithubappcompose)
- Tags: how-to-guide
- Published: 2026-02-26

---

**The GSY GitHub App implements a unidirectional data flow where ViewModels hold private `MutableStateFlow` instances and expose read-only `StateFlow` to the UI, using a `BaseViewModel` abstraction to centralize pagination, loading states, and error handling across all screens.**

The `carguo/gsygithubappcompose` repository demonstrates a production-ready architecture for managing UI state using StateFlow and ViewModel. By leveraging Kotlin coroutines and immutable data classes, the app ensures that UI updates are predictable, lifecycle-aware, and fully compatible with Jetpack Compose's reactive programming model.

## Core Architecture Components

The architecture relies on a small set of Kotlin APIs that work together to create a strict unidirectional data flow:

| Component | Responsibility | Key Kotlin APIs |
|-----------|----------------|-----------------|
| **ViewModel** | Lifecycle-aware holder of UI state, launches coroutines in `viewModelScope` | `viewModelScope.launch { … }` |
| **MutableStateFlow** | Mutable holder of the UI state (private) | `MutableStateFlow(initialState)` |
| **StateFlow** | Read-only view of the state exposed to the UI | `asStateFlow()` |
| **MutableSharedFlow** | One-time events (e.g., toast messages) | `MutableSharedFlow<T>()` |
| **Composable UI** | Subscribes to the `StateFlow` and recomposes on changes | `collectAsState()` |

All ViewModels in the codebase follow the same encapsulation pattern:

```kotlin
private val _uiState = MutableStateFlow(MyUiState())   // mutable, private
val uiState: StateFlow<MyUiState> = _uiState.asStateFlow() // read-only, public

```

## The BaseViewModel Abstraction

Located at [`data/src/main/java/com/shuyu/gsygithubappcompose/data/repository/vm/BaseViewModel.kt`](https://github.com/carguo/gsygithubappcompose/blob/main/data/src/main/java/com/shuyu/gsygithubappcompose/data/repository/vm/BaseViewModel.kt), the abstract `BaseViewModel` class centralizes pagination, loading flags, and error handling for every screen in the app.

### BaseUiState Interface

The interface defines the common fields required for any paginated screen:

```kotlin
interface BaseUiState {
    val isPageLoading: Boolean
    val isRefreshing: Boolean
    val isLoadingMore: Boolean
    val error: String?
    val currentPage: Int
    val hasMore: Boolean
    val loadMoreError: Boolean
}

```

### State Holder Pattern

The base class creates the `MutableStateFlow` from an `initialUiState` provided by concrete implementations:

```kotlin
protected val _uiState = MutableStateFlow(initialUiState)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()

```

### Common Operations

- **`updateUiStateWithCommonProperties`** – Merges pagination flags and error messages using a lambda supplied by the concrete ViewModel.
- **`launchDataLoad`** – Sets loading flags before executing a suspend function in `viewModelScope`.
- **`handleResult`** – Updates the UI after a network fetch, toggling `hasMore` based on `NetworkConfig.PER_PAGE`, resetting loading flags, and optionally emitting a toast through `MutableSharedFlow<String>`.

## Concrete ViewModel Implementations

### Simple Navigation State

In [`feature/welcome/src/main/java/com/shuyu/gsygithubappcompose/feature/welcome/WelcomeViewModel.kt`](https://github.com/carguo/gsygithubappcompose/blob/main/feature/welcome/src/main/java/com/shuyu/gsygithubappcompose/feature/welcome/WelcomeViewModel.kt), the splash screen uses a minimal `StateFlow` to drive navigation:

```kotlin
private val _navigationDestination = MutableStateFlow<String?>(null)
val navigationDestination: StateFlow<String?> = _navigationDestination.asStateFlow()

```

After a delay, the ViewModel checks `UserRepository.isLoggedIn()` and emits either "home" or "login" to the flow, which the UI observes to trigger navigation.

### Rich Pagination and Error Handling

`NotificationViewModel` in [`feature/notification/src/main/java/com/shuyu/gsygithubappcompose/feature/notification/NotificationViewModel.kt`](https://github.com/carguo/gsygithubappcompose/blob/main/feature/notification/src/main/java/com/shuyu/gsygithubappcompose/feature/notification/NotificationViewModel.kt) demonstrates the full `BaseViewModel` pattern:

```kotlin
private val _uiState = MutableStateFlow(NotificationUiState())
val uiState = _uiState.asStateFlow()

```

The `load(isRefresh: Boolean)` function:
1. Checks existing loading flags to prevent duplicate requests.
2. Determines the target page based on `currentPage`.
3. Emits a temporary state with `isPageLoading`, `isRefreshing`, or `isLoadingMore` set to true.
4. Calls `notificationRepository.getNotifications(...)`.
5. On success, concatenates new items, updates `hasMore` using `notifications.size >= NetworkConfig.PER_PAGE`, and increments `page`.
6. On failure, sets the `error` field or `loadMoreError` for pagination errors.
7. Finally clears all loading flags via the base class.

### Multiple Result Types and Shared Events

`SearchViewModel` in [`feature/search/src/main/java/com/shuyu/gsygithubappcompose/feature/search/SearchViewModel.kt`](https://github.com/carguo/gsygithubappcompose/blob/main/feature/search/src/main/java/com/shuyu/gsygithubappcompose/feature/search/SearchViewModel.kt) manages separate flows for the search query, repositories, and users:

```kotlin
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateAsFlow()

```

The `performSearch` function sets loading flags, launches the appropriate repository call based on search type, and updates the result list. Errors are emitted through a `MutableSharedFlow<String>`:

```kotlin
private val _toastMessage = MutableSharedFlow<String>()
val toastMessage: SharedFlow<String> = _toastMessage.asSharedFlow()

```

## Consuming State in Jetpack Compose

The UI layer subscribes to ViewModel state using `collectAsState()`. In [`feature/notification/src/main/java/com/shuyu/gsygithubappcompose/feature/notification/NotificationScreen.kt`](https://github.com/carguo/gsygithubappcompose/blob/main/feature/notification/src/main/java/com/shuyu/gsygithubappcompose/feature/notification/NotificationScreen.kt):

```kotlin
@Composable
fun NotificationScreen(viewModel: NotificationViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsState()
    // UI reacts to uiState.isPageLoading, uiState.notifications, uiState.error, …
}

```

The `collectAsState()` extension creates a snapshot of the current `StateFlow` value and automatically triggers recomposition when the flow emits a new value. Loading indicators, empty states, and pagination controls are driven entirely by the boolean flags and data lists inside the immutable UI state data class.

## Handling One-Off UI Events

For transient events like toast messages that should not trigger recomposition, ViewModels use `MutableSharedFlow`. As implemented in [`BaseViewModel.kt`](https://github.com/carguo/gsygithubappcompose/blob/main/BaseViewModel.kt):

```kotlin
private val _toastMessage = MutableSharedFlow<String>()
val toastMessage: SharedFlow<String> = _toastMessage.asSharedFlow()

```

The ViewModel emits events with `_toastMessage.emit(message)`, while the UI collects them within a `LaunchedEffect` to display a Snackbar only once. This pattern appears in `SearchViewModel.performSearch` and `BaseViewModel.handleResult` for error propagation.

## Benefits of This Architecture

| Benefit | Explanation |
|---------|-------------|
| **Single source of truth** | All UI elements read from the same immutable state object; no duplicated flags. |
| **Predictable recomposition** | `StateFlow` emits only when the value actually changes, avoiding unnecessary UI work. |
| **Testability** | ViewModels can be unit-tested by inspecting the `StateFlow` values after invoking actions. |
| **Lifecycle safety** | `viewModelScope` ties coroutine lifetimes to the ViewModel, preventing leaks. |
| **Consistent error handling** | `BaseViewModel` centralises toast/error emission, ensuring a uniform UX. |
| **Scalable pagination** | Common loading/pagination fields (`isLoadingMore`, `hasMore`) are baked into `BaseUiState`. |

## Summary

- **StateFlow** provides a lifecycle-aware, read-only observable state holder that integrates seamlessly with Jetpack Compose via `collectAsState()`.
- **BaseViewModel** in [`data/src/main/java/com/shuyu/gsygithubappcompose/data/repository/vm/BaseViewModel.kt`](https://github.com/carguo/gsygithubappcompose/blob/main/data/src/main/java/com/shuyu/gsygithubappcompose/data/repository/vm/BaseViewModel.kt) eliminates boilerplate by standardizing pagination, loading flags, and error handling through the `BaseUiState` interface.
- Concrete implementations like `NotificationViewModel` and `SearchViewModel` demonstrate how to extend the base class, update state immutably using `update { ... }`, and emit one-off events via `MutableSharedFlow`.
- The UI layer remains purely reactive, observing state changes without business logic, which ensures testability and predictable recomposition.

## Frequently Asked Questions

### How does StateFlow differ from LiveData when managing UI state?

`StateFlow` is a Kotlin coroutines-based API that requires an initial value and never returns null, whereas `LiveData` is lifecycle-aware but often requires null checks and manual value setting. In the GSY GitHub App, `StateFlow` enables seamless integration with `viewModelScope` and Compose's `collectAsState()` extension, providing more predictable recomposition and better support for cold streams transformed from network calls.

### Why use MutableSharedFlow instead of StateFlow for toast messages?

`StateFlow` is designed for state that always has a value and replays the latest value to new collectors, which is inappropriate for one-time events like toast messages. If the UI recomposes (e.g., during configuration changes), a `StateFlow` would re-emit the last toast message, causing duplicate notifications. The app uses `MutableSharedFlow` with no replay for `toastMessage`, ensuring that events are consumed exactly once by the UI using `LaunchedEffect`.

### How does BaseViewModel handle pagination state consistently?

`BaseViewModel` defines the `BaseUiState` interface with standardized fields like `isLoadingMore`, `hasMore`, and `currentPage`. Concrete ViewModels provide a lambda to `commonStateUpdater` that copies their specific UI state while updating these common pagination flags. When `loadData` is called, the base class manages the page calculation, sets loading flags via `updateLoadingState`, and invokes `handleResult` to immutably update the state with new items and pagination metadata, ensuring every screen follows the same loading/error pattern.

### Can this architecture be tested without the Android framework?

Yes, because ViewModels depend only on `viewModelScope` (which can be replaced with a `TestDispatcher`) and expose plain Kotlin `StateFlow` instances. Unit tests can instantiate concrete ViewModels with mocked repositories, invoke public methods like `load(isRefresh = true)`, and assert on the `uiState` flow values using ` Turbine` or `stateIn` without launching an Android emulator. The immutability of state updates also makes assertions deterministic, as each state change represents a complete snapshot of the UI.