MyFamilyTracker
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
Learn how to replace LiveData with Kotlin Flow in a Room‑based architecture, see real code, and make your Android app truly reactive.
On this page
Android apps are only as fast as their data layer. If you’re still using LiveData or manual threading, you’re leaving performance on the table.
Context
For the past decade I’ve shipped 22+ apps from Bangkok, and I’ve seen the same pattern repeat: a Room database built for CRUD, a ViewModel exposing LiveData, and UI components that observe that LiveData. The result is tight coupling, hard‑to‑test code, and UI jitter when data changes. Kotlin Flow gives us a clean, cold‑stream approach that pairs naturally with Room’s suspend APIs. In this post I’ll show you how to wire Room directly into a Flow‑based repository, expose it from a ViewModel, and consume it in Jetpack Compose without any boilerplate. You’ll walk away with a production‑ready reactive data layer you can drop into any Android project today.
Traditional Android architectures rely on LiveData or RxJava to push UI updates. While functional, they introduce several pain points:
| Aspect | LiveData | Kotlin Flow |
|---|---|---|
| Cold vs Hot | Hot (emits immediately) | Cold (emits on demand) |
| Lifecycle awareness | Automatic (LifecycleObserver) | Requires explicit collection handling |
| Error handling | Separate try/catch needed | Propagates as a Throwable in the flow |
| Testing | Requires UI thread setup | Can be collected in test coroutines |
| Backpressure | Limited | Built‑in via flow operators |
[!TIP] Use Flow when you need fine‑grained control over data emission and want to avoid unnecessary recompositions.
[!NOTE] Flow works best when the underlying data source is already suspend‑capable, which is exactly what Room provides.
The real benefit appears when you combine Room’s
@QueryFirst, define a DAO that returns a Flow. Room can turn any
suspendList<Entity>@Dao
interface UserDao {
@Query("SELECT * FROM user")
fun getAllUsers(): Flow<List<User>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(user: User)
@Delete
suspend fun delete(user: User)
}Next, create a Repository that exposes the DAO’s Flow as the single source of truth.
class UserRepository(private val dao: UserDao) {
// Public Flow that UI will collect
val allUsers: Flow<List<User>> = dao.getAllUsers()
.flowOn(Dispatchers.IO) // Ensure DB work runs off the main thread
.catch { throw it } // Propagate errors downstream
}Now, the ViewModel simply exposes the repository’s Flow as
StateFlow@HiltViewModel
class UserViewModel @Inject constructor(
private val repository: UserRepository
) : ViewModel() {
// Transform the cold Flow into a hot StateFlow
val users: StateFlow<List<User>> = repository.allUsers
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}[!IMPORTANT]
is the key operator that turns a Flow into a StateFlow while respecting the ViewModel’s lifecycle.codestateIn
Because the DAO methods are
suspendfun addUser(name: String, age: Int) {
viewModelScope.launch {
repository.insert(User(name = name, age = age))
}
}If you need to combine streams — say, fetch users and also listen to a sync flag — use
zipcombineval usersWithSync: Flow<List<User>> = repository.allUsers
.combine(latestSyncFlag) { users, sync -> /* combine logic */ }[!WARNING] Never collect a Flow on the main thread; always use
orcodeDispatchers.IOto keep UI responsive.codeviewModelScope
Jetpack Compose loves StateFlow. The UI layer simply collects the flow as state, which triggers recomposition only when the list actually changes.
@Composable
fun UserScreen(viewModel: UserViewModel = hiltViewModel()) {
val users by viewModel.users.collectAsState()
LazyColumn {
items(users) { user ->
UserRow(user = user)
}
}
}If you want to react to a single item update, you can expose a separate Flow from the repository (e.g.,
Flow<User>[!NOTE]
automatically manages the coroutine scope and cancels collection when the composable leaves the composition, preventing leaks.codecollectAsState()
A common pattern is to keep a local cache (Room) and periodically refresh it from a network layer. Here’s a concise example:
class SyncRepository(
private val dao: UserDao,
private val api: UserApi
) {
val freshUsers: Flow<List<User>> = flow {
// 1️⃣ Refresh from network
val remote = api.getUsers()
// 2️⃣ Insert into Room (suspend)
dao.insertAll(remote.toEntityList())
// 3️⃣ Emit the updated list
emit(dao.getAllUsers())
}
.flowOn(Dispatchers.IO)
.catch { /* handle network errors */ }
}The UI can observe
freshUsersTesting Flow‑based code is straightforward with the
runTest@Test
fun `repository emits updated list after insert`() = runTest {
// Given
val dao = mockk<UserDao>()
val repo = UserRepository(dao)
val mutableList = mutableListOf<User>()
coEvery { dao.getAllUsers() } returns flowOf(mutableList)
// When
repo.insert(User(name = "Alice", age = 30))
// Then
val emitted = advanceUntilIdle()
assertThat(emitted).hasSize(1)
assertThat(emitted.first()).containsExactly(User(name = "Alice", age = 30))
}[!TIP] Use
to let all pending flow emissions complete before asserting.codeadvanceUntilIdle()
stateIncollectAsState()Dispatchers.IOviewModelScoperunTestBy adopting Kotlin Flow alongside Android Room, you gain a reactive, testable, and performant data layer that scales with the complexity of modern Android applications. Start integrating Flow today, and watch your apps become faster, more reliable, and easier to maintain.
Sudarshan Chaudhari
AI Systems Builder / Product Engineer
Bangkok, Thailand
Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.
Related Posts
Related Apps
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
Building something? Available for Android dev and QA consulting.
Work with meComments — powered by Giscus
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
ReadPrivate dream journal — structured entry capture, pattern tagging, and optional Claude-powered insight generation. All data stays on-device by default.
ReadWorkout tracker — exercise logging with set/rep/weight history, goal progression, and local Room DB persistence. No account, no cloud sync required.
Read