Data Flow from API Response to UI State Transformation in gsygithubappcompose
The gsygithubappcompose repository implements a reactive, unidirectional data flow where raw GitHub API responses travel through cold Kotlin Flows, are transformed into UI-specific display models by the ViewModel, and emitted as immutable StateFlow states that drive Jetpack Compose recomposition.
The carguo/gsygithubappcompose project demonstrates modern Android architecture patterns using Jetpack Compose and coroutines-based data streams. Understanding the complete data flow from API response to UI state transformation reveals how the application maintains strict separation of concerns while ensuring reactive UI updates through type-safe state management.
Step 1: Triggering Requests from the Compose UI Layer
The data flow initiates when a Compose screen calls loadData() on the ListViewModel within a LaunchedEffect. The UI layer observes state changes through collectAsState(), which automatically triggers recomposition when the underlying StateFlow emits new values.
// UI trigger in Compose screen
LaunchedEffect(user, repo, type) {
viewModel.loadData(user, repo, type.name)
}
// State observation
val state by viewModel.uiState.collectAsState()
LazyColumn {
items(state.list) { item ->
RepositoryItem(repoItem = item as RepoItemDisplayData)
}
}
In feature/list/src/main/java/com/shuyu/gsygithubappcompose/feature/list/ListViewModel.kt, the loadData() method determines which repository to query based on the CommonListDataType enum, then delegates to the appropriate repository method.
Step 2: ViewModel Orchestration with StateFlow
The ListViewModel manages UI state through a MutableStateFlow<ListUIState> backing field exposed as an immutable StateFlow. When loadData() is invoked, the ViewModel launches a coroutine in viewModelScope to collect a cold Flow from the repository layer.
private fun load(isRefresh: Boolean) {
viewModelScope.launch {
// Set loading flags
_uiState.value = _uiState.value.copy(
isPageLoading = loadPage == 1 && !isRefresh,
isRefreshing = isRefresh,
isLoadingMore = loadPage > 1
)
// Select repository method based on list type
val resultFlow = when (listType) {
CommonListDataType.REPOSITORIES ->
reposRepository.getUserRepos(userName!!, loadPage, "pushed")
// ... other list types
}
// Collect the cold flow
resultFlow?.onEach { result ->
result.onSuccess { data ->
// Mapping happens here (see Step 4)
val mapped = data.map { it as? Repository }
.mapNotNull { it?.toRepositoryDisplayData() }
_uiState.value = _uiState.value.copy(
list = (if (isRefresh) emptyList() else _uiState.value.list) + mapped,
hasMore = data.size == NetworkConfig.PER_PAGE,
title = stringResourceProvider.getListTitle(listType)
)
}
result.onFailure { e ->
_uiState.value = _uiState.value.copy(error = e.message)
}
// Clear loading states
_uiState.value = _uiState.value.copy(
isPageLoading = false,
isRefreshing = false,
isLoadingMore = false
)
}?.launchIn(viewModelScope)
}
}
The ViewModel handles three distinct loading states—isPageLoading, isRefreshing, and isLoadingMore—to support pagination and pull-to-refresh gestures while maintaining a single source of truth in _uiState.
Step 3: Repository Layer and Cold Flow Emission
The data layer in data/src/main/java/com/shuyu/gsygithubappcompose/data/repository/RepositoryRepository.kt exposes methods that return Flow<Result<List<T>>>, creating a cold flow that only executes when collected by the ViewModel. This repository coordinates between the network API and local cache.
// RepositoryRepository.kt provides cold flows
fun getUserRepos(userName: String, page: Int, sort: String): Flow<Result<List<Repository>>> = flow {
// Optional: emit cached data from Room first
val cached = repositoryDao.getRepositories(userName, page)
if (cached.isNotEmpty()) emit(Result.success(cached))
// Network request via Retrofit
try {
val response = gitHubApiService.getUserRepositories(userName, page, NetworkConfig.PER_PAGE, sort)
repositoryDao.insertAll(response) // Cache result
emit(Result.success(response))
} catch (e: Exception) {
emit(Result.failure(e))
}
}
The GitHubApiService interface defined in core/network/src/main/java/com/shuyu/gsygithubappcompose/core/network/api/GitHubApiService.kt handles the actual Retrofit calls, while the repository manages the Result wrapper and pagination logic using NetworkConfig.PER_PAGE.
Step 4: Network-to-UI Model Transformation
Raw network models defined in core/network/src/main/java/com/shuyu/gsygithubappcompose/core/network/model/Repository.kt contain complete GitHub API fields, but the UI only requires a subset. The mapping occurs through extension functions in core/ui/src/main/java/com/shuyu/gsygithubappcompose/core/ui/components/RepositoryItem.kt.
// Network model (simplified)
data class Repository(
val id: Long,
val name: String,
val fullName: String,
val description: String?,
val language: String?,
val stargazersCount: Int,
val forksCount: Int,
val owner: Owner
)
// UI-ready model implementing sealed interface
data class RepositoryDisplayData(
val fullName: String,
val description: String?,
val language: String?,
val starCount: String,
val forkCount: String,
val avatarUrl: String,
val ownerName: String,
val name: String
) : RepoItemDisplayData
// Extension function performing the transformation
fun Repository.toRepositoryDisplayData(): RepositoryDisplayData = RepositoryDisplayData(
fullName = fullName,
description = description,
language = language,
starCount = stargazersCount.toString(),
forkCount = forksCount.toString(),
avatarUrl = owner.avatarUrl,
ownerName = owner.login,
name = name
)
This transformation extracts only presentation-relevant fields and converts numeric counts to formatted strings, ensuring the UI layer never directly references raw API models.
Step 5: UI Recomposition and Final Rendering
The composable screen reads the mapped RepositoryDisplayData objects from uiState and renders them through the RepositoryItem composable. Because the data is already transformed, the UI component focuses purely on presentation logic without parsing or conversion overhead.
@Composable
fun RepositoryItem(
repoItem: RepoItemDisplayData,
modifier: Modifier = Modifier
) {
// repoItem is guaranteed to be RepositoryDisplayData
val data = repoItem as RepositoryDisplayData
Card(modifier = modifier) {
Row {
AsyncImage(model = data.avatarUrl, contentDescription = null)
Column {
Text(text = data.fullName)
Text(text = data.description ?: "")
Text(text = "⭐ ${data.starCount} 🍴 ${data.forkCount}")
}
}
}
}
The LazyColumn recomposes automatically whenever the ViewModel emits a new ListUIState, creating a reactive loop where API data flows unidirectionally from network to screen without manual synchronization.
Summary
- Cold Flow Architecture: Repository methods return
Flow<Result<T>>that activate only upon collection inviewModelScope, preventing premature network requests. - Immutable State Management: The
ListViewModelmaintains a singleStateFlow<ListUIState>that encapsulates loading flags, error messages, pagination status, and the transformed data list. - Type-Safe Mapping: Network models in
core/network/modelare converted to UI models via extension functions liketoRepositoryDisplayData(), enforcing boundary separation between data and presentation layers. - Reactive UI: Compose screens observe state through
collectAsState()and renderRepositoryItemcomposables using the pre-mappedRepoItemDisplayDatainterface implementations.
Frequently Asked Questions
How does the ViewModel handle different list types (repositories, stars, issues)?
The ListViewModel uses a when expression on the CommonListDataType enum to select the appropriate repository method. For CommonListDataType.REPOSITORIES, it calls reposRepository.getUserRepos(); for starred repositories or issues, it invokes different repository methods that return the same Flow<Result<List<T>>> structure, allowing uniform state handling regardless of data source.
Why does the repository use a cold Flow instead of suspend functions?
Cold Flows ensure that network requests begin only when the ViewModel actively collects them, preventing unnecessary API calls during configuration changes or rapid user interactions. The Flow also enables seamless emission of cached Room data before network results arrive, providing immediate UI feedback while refreshing data in the background.
What happens to raw API fields that are not included in RepositoryDisplayData?
Fields like internal GitHub IDs, permissions objects, or raw timestamps are stripped during the mapping process in toRepositoryDisplayData(). This minimizes memory overhead in the UI layer and prevents tight coupling between API schema changes and presentation logic, as the ViewModel acts as an anti-corruption layer translating external contracts to internal UI requirements.
How is pagination state maintained during the transformation?
The ViewModel tracks hasMore by comparing the received list size against NetworkConfig.PER_PAGE constant. When mapping data in the onEach block, it appends new items to the existing list (or replaces them during refresh) and updates the pagination flag in the same ListUIState emission, ensuring the UI knows whether to show "load more" indicators.
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 →