Stop Calling notifyDataSetChanged: Room Flow and Lifecycle-Aware UI State
Room DAOs returning Flow eliminate manual refresh calls by re-querying on table changes. Here is how to wire StateFlow and stateIn into your ViewModel without stale UI bugs.
18 Feb 2026, 14:55 UTC

The stale RecyclerView problem
You insert a task from a background worker, then call notifyDataSetChanged() from the wrong thread, or you forget to refresh after a swipe-to-delete. The list shows stale data until the user navigates away and back. Manual refresh calls are fragile because they couple every write site to every read site.
Let Room re-run the query for you
Room can return a Flow from a DAO method. When any table referenced by the query changes, Room's invalidation tracker re-executes the SQL and emits a new list. You no longer call refresh after writes; the write itself triggers the emission.
Define a DAO that returns Flow<List<Task>>:
@Dao
interface TaskDao {
@Query("SELECT * FROM tasks ORDER BY created_at DESC")
fun observeAll(): Flow<List<Task>>
@Insert
suspend fun insert(task: Task)
}
Flow is cold. Without an active collector, database changes do not trigger re-execution. This is a feature, not a bug: it prevents wasted queries when the UI is not visible.
Make the stream survive rotation with stateIn
Collecting a Flow directly in a Fragment means re-subscribing on every configuration change and re-querying from scratch. Instead, collect in the ViewModel and expose a StateFlow using stateIn.
class TaskViewModel(private val dao: TaskDao) : ViewModel() {
val tasks: StateFlow<List<Task>> = dao.observeAll()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
}
stateIn converts the cold Flow into a hot StateFlow that caches the latest value. When you rotate the device, the new Fragment receives the cached list immediately instead of waiting for a fresh query. The WhileSubscribed(5_000) policy keeps the upstream alive briefly during rotation so Room does not tear down and rebuild the invalidation registration.
Worked example: background insert updates the UI
Assume a WorkManager worker inserts a task while the app is in the foreground. Because the ViewModel is collecting the DAO flow, the insert triggers Room's invalidation tracker, the query re-runs, and the new list flows to the UI.
In your Fragment:
viewLifecycleOwner.lifecycleScope.launch {
viewModel.tasks.collect { list ->
adapter.submitList(list)
}
}
No notifyDataSetChanged() call. No manual refresh after the worker finishes.
Room executes the generated Flow query on a background executor, so the database read never blocks the main thread. However, downstream operators such as map or combine run in the collector's context. If you collect on the main thread and perform heavy transformation there, you will drop frames. Shift that work explicitly:
val uiTasks: StateFlow<List<UiTask>> = dao.observeAll()
.map { entities -> entities.map { it.toUiModel() } }
.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
Separately, if you call a synchronous DAO method on the main thread, Room's default configuration throws in debug builds. Flow queries avoid this by design, but the main-thread guard is a useful reminder to keep all database access asynchronous.
Trade-offs and gotchas
Room's invalidation tracker works at the table level. If your query joins tasks and users, an insert into users re-runs the query even if the joined result is unchanged. For write-heavy tables with expensive queries, this can produce redundant emissions. Profile with the Database Inspector or log emission counts if you suspect churn.
Because Flow is cold, background writes do not trigger re-execution while the UI is stopped. When the UI restarts, stateIn replays the cached value, but if the ViewModel was cleared, the next collection starts a fresh query. That is usually correct, but it means you cannot rely on background emissions while the app is backgrounded.
Testing reactive Room code requires injecting the same dispatcher used by stateIn. In unit tests, use runTest and pass a test dispatcher into the ViewModel so assertions wait for emissions instead of racing them.
Finally, confirm your Room version is compatible with your Kotlin compiler. Room's KSP integration and Flow APIs have shifted across releases; a mismatched version can cause generated-code errors that are easy to mistake for bugs in your own DAO.
Actionable checklist
- Change DAO read methods to return
Flow<List<Entity>>instead ofList<Entity>. - Collect in the ViewModel and expose
StateFlowviastateInwithSharingStarted.WhileSubscribed. - Move CPU-intensive
maporcombinework toDispatchers.IOwithflowOn. - Rotate the device and confirm the list appears instantly from cache rather than showing an empty state.
- Insert a row from a background worker and verify the RecyclerView updates without any manual notify call.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.