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 inviewModelScope.handleResult– Updates the UI after a network fetch, togglinghasMorebased onNetworkConfig.PER_PAGE, resetting loading flags, and optionally emitting a toast throughMutableSharedFlow<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:
- Checks existing loading flags to prevent duplicate requests.
- Determines the target page based on
currentPage. - Emits a temporary state with
isPageLoading,isRefreshing, orisLoadingMoreset to true. - Calls
notificationRepository.getNotifications(...). - On success, concatenates new items, updates
hasMoreusingnotifications.size >= NetworkConfig.PER_PAGE, and incrementspage. - On failure, sets the
errorfield orloadMoreErrorfor pagination errors. - 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.kteliminates boilerplate by standardizing pagination, loading flags, and error handling through theBaseUiStateinterface. - Concrete implementations like
NotificationViewModelandSearchViewModeldemonstrate how to extend the base class, update state immutably usingupdate { ... }, and emit one-off events viaMutableSharedFlow. - 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →