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

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:

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, 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:

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:

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, the splash screen uses a minimal StateFlow to drive navigation:

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 demonstrates the full BaseViewModel pattern:

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 manages separate flows for the search query, repositories, and users:

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>:

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:

@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:

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 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →