Skip to content

How Data Flows from Repository to UI in Jetpack Compose

Problem

When I started learning Jetpack Compose architecture, I got confused about how data moves between layers. I saw code with Flow, StateFlow, collectAsState(), and MutableStateFlow. I didn’t know where each belongs or how they connect.

I tried to follow tutorials, but they either skipped the details or showed everything at once without explaining the transformation points.

Environment

  • Kotlin 1.9
  • Jetpack Compose BOM 2024.x
  • Hilt for dependency injection
  • Room database
  • Coroutines and Flow

What happened?

I was building a task list app. I created a Repository, a ViewModel, and a Compose UI. But when I ran the app, the UI showed nothing. No tasks loaded. No errors either.

Here’s my setup:

TaskRepository.kt
// Repository returns Flow
class TaskRepository @Inject constructor(
private val taskDao: TaskDao
) {
fun getTasks(): Flow<List<Task>> {
return taskDao.getAllTasks()
.map { entities -> entities.map { it.toDomain() } }
}
}
TaskListViewModel.kt
// ViewModel should expose StateFlow
@HiltViewModel
class TaskListViewModel @Inject constructor(
private val repository: TaskRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<TaskListUiState>(TaskListUiState.Loading)
val uiState: StateFlow<TaskListUiState> = _uiState.asStateFlow()
init {
loadTasks()
}
fun loadTasks() {
// How do I connect repository to uiState?
}
}
TaskListScreen.kt
@Composable
fun TaskListScreen(
viewModel: TaskListViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
// How to render based on state?
}

The core problem: I didn’t understand how data transforms between Repository (Flow) and ViewModel (StateFlow) and finally UI (State).

How to solve it?

I tried connecting Repository to ViewModel directly:

First Attempt
fun loadTasks() {
// This doesn't work - Flow is cold!
val tasks = repository.getTasks()
_uiState.value = TaskListUiState.Success(tasks)
}

Nothing happened. The Flow didn’t emit any data because it’s cold - it only emits when collected.

I tried collecting the Flow:

Second Attempt
fun loadTasks() {
viewModelScope.launch {
repository.getTasks().collect { tasks ->
_uiState.value = TaskListUiState.Success(tasks)
}
}
}

This worked! But I was confused about the difference between collect() and collectAsState() in the UI.

Then I discovered stateIn() as an alternative:

Third Attempt - Using stateIn
val uiState: StateFlow<TaskListUiState> = repository.getTasks()
.map { tasks -> TaskListUiState.Success(tasks) as TaskListUiState }
.catch { e -> emit(TaskListUiState.Error(e.message ?: "Unknown error")) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = TaskListUiState.Loading
)

This is cleaner for simple cases. But manual collection gives more control over error handling.

Now let me show the complete working solution:

TaskListViewModel.kt - Complete
@HiltViewModel
class TaskListViewModel @Inject constructor(
private val repository: TaskRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<TaskListUiState>(TaskListUiState.Loading)
val uiState: StateFlow<TaskListUiState> = _uiState.asStateFlow()
init {
observeTasks()
}
private fun observeTasks() {
viewModelScope.launch {
repository.getTasks()
.catch { e ->
_uiState.value = TaskListUiState.Error(e.message ?: "Unknown error")
}
.collect { tasks ->
_uiState.value = TaskListUiState.Success(tasks)
}
}
}
fun onTaskToggle(taskId: String) {
viewModelScope.launch {
repository.toggleTaskCompletion(taskId)
// No need to manually refresh - Flow auto-emits new data
}
}
}
sealed interface TaskListUiState {
data object Loading : TaskListUiState
data class Success(val tasks: List<Task>) : TaskListUiState
data class Error(val message: String) : TaskListUiState
}
TaskListScreen.kt - Complete
@Composable
fun TaskListScreen(
viewModel: TaskListViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
when (val state = uiState) {
is TaskListUiState.Loading -> {
item {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
is TaskListUiState.Success -> {
items(state.tasks) { task ->
TaskItem(
task = task,
onToggle = { viewModel.onTaskToggle(task.id) }
)
}
}
is TaskListUiState.Error -> {
item {
Text(
text = "Error: ${state.message}",
color = MaterialTheme.colorScheme.error
)
}
}
}
}
}

You can see that I succeeded to load and display tasks. The UI updates automatically when database changes.

The data flow

I want to show the complete data flow diagram:

Data Flow Diagram
┌─────────────────────────────────────────────────────────┐
│ USER INTERACTION │
└─────────────────────────┬───────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ UI LAYER (Compose) │
│ collectAsState() -> State │
│ Renders based on state │
└─────────────────────────┬───────────────────────────────┘
│ StateFlow<UiState>
┌─────────────────────────┴───────────────────────────────┐
│ VIEWMODEL LAYER │
│ - Holds StateFlow (hot) │
│ - Collects Repository Flow │
│ - Exposes immutable state to UI │
└─────────────────────────┬───────────────────────────────┘
│ Flow<T>
┌─────────────────────────┴───────────────────────────────┐
│ REPOSITORY LAYER │
│ - Exposes Flow (cold) │
│ - Single source of truth │
│ - Combines data sources (API + DB) │
└─────────────────────────┬───────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ DATA SOURCES │
│ - Remote API │
│ - Local Database (Room) │
└─────────────────────────────────────────────────────────┘

The flow has two directions:

Downward (Data):

  1. Repository exposes Flow&lt;T&gt; (cold)
  2. ViewModel collects Flow and emits to StateFlow&lt;UiState&gt; (hot)
  3. UI observes StateFlow via collectAsState()
  4. UI recomposes when state changes

Upward (Events):

  1. User interacts with UI
  2. UI calls ViewModel function
  3. ViewModel calls Repository suspend function
  4. Repository updates data source
  5. Flow emits new value, cycle repeats

The reason

I think the key reason I struggled is that I missed the transformation points:

Point 1: Flow vs StateFlow

Flow vs StateFlow
┌─────────────────────────────────────────────────────────┐
│ FLOW (Cold) │
│ - Only emits when collected │
│ - Multiple collectors possible │
│ - No initial value │
│ - Use in Repository │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ STATEFLOW (Hot) │
│ - Always has a value │
│ - Caches latest for new collectors │
│ - Holds state for UI │
│ - Use in ViewModel │
└─────────────────────────────────────────────────────────┘

Point 2: Repository to ViewModel conversion

The Repository returns cold Flow because it’s just a data source. The ViewModel converts it to hot StateFlow because it needs to cache state for the UI.

Point 3: ViewModel to UI connection

collectAsState() converts StateFlow to Compose State. This is lifecycle-aware - it collects when the composable is active and cancels when it’s destroyed.

Bidirectional flow example

Let me show how user actions flow back up:

Bidirectional Data Flow
USER CLICKS TASK
UI -> viewModel.onTaskToggle(taskId)
ViewModel -> repository.toggleTaskCompletion(taskId)
Repository -> taskDao.update(...)
Database emits new value via Flow
ViewModel.collect() receives update
StateFlow<UiState> emits new state
collectAsState() triggers recomposition
UI updates with new data

No manual refresh needed. The reactive chain handles it.

Summary

In this post, I showed how data flows from Repository to UI in Jetpack Compose. The key point is understanding the transformation points: Repository exposes cold Flow, ViewModel converts to hot StateFlow for caching, and UI observes via collectAsState(). User events flow up through function calls that trigger data updates, completing the cycle.

The pattern is simple once you trace it:

  • Data flows down through reactive streams
  • Events flow up through function calls
  • Each layer has a single responsibility
  • StateFlow in ViewModel is the bridge

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments