
Source: Gemini Image Gen
Why this article?
I’ve built 15+ apps and read thousands of articles on Kotlin Dev, and I’ve seen so many, so many ways to load data for display on the UI that are wrong that I’m worried about all of the people who are unknowingly using those.
The worst thing is — they keep coming! Every month I see another article / video / code sample with a flawed implementation of either events or data loading strategies that cause hidden bugs that will likely only surface in production.
Do you want to know if you’re using one of them? Fix the issues? Then this article is for you, because this is the definitive guide you need for loading data correctly in Kotlin apps.
Our example:
For illustration purposes, let us say that we have an app which loads a homepage with:
- User profile info, backed by a local cache file + network endpoint.
- Some news articles for the user to read, from the network, without file caching.
The requirements are:
- We must only show up-to-date articles while the user is browsing the page.
- The user may be able to open additional pages on top of our homepage, and edit their profile info or article preferences. When they return, we must update the data.
Now let’s say we’re trying to satisfy the requirements, and have already built our repository.
interface HomeRepository { suspend fun getArticles(): List<Article> // load articles from network, only possible while signed in val user: Flow<User> // locally cached user, auto updated on refreshUser() suspend fun refreshUser(): User // update user data from the BE } sealed interface HomeState { data object Loading: HomeState data object DisplayingFeed(val user: User, val feed: List<Article>): HomeState }
If you’re confused on why we structured our state this way, this is covered in my article on state management (not important right now). Some notes:
- We have omitted the UI model mapping and pagination on purpose for the sake of simplicity
- Keep in mind that I also omitted error handling and user authentication handling to simplify the code
And now it is the time to load that data, as an example, in our Compose application on an Android target.
Part 1: What NOT to do
The best way to figure out what to do in our case is to begin by eliminating what not to do, and seeing what is left.
1. Do not load eagerly!
Let’s say that naively, we wrote the following code:
class HomeViewModel( private val repo: HomeRepository, ): ViewModel() { private val _state = MutableStateFlow<HomeState>(HomeState.Loading) val state = _state.asStateFlow() init { viewModelScope.launch { val user = repo.refreshUser() val articles = repo.getArticles() _state.value = DisplayingFeed(user, articles) } } }
This code has several resource leaks, races and UX flaws:
1. Stale Data:
The data is fetched only once when the ViewModel is created.
- If the app goes into the background and returns hours (or even days/weeks!) later, the user sees outdated information. Modern phones can keep apps suspended for a long time.
- If the user navigates away (e.g., to a profile editing screen), the
HomeViewModel
often stays alive in the backstack. When they return after making changes, they’ll still see the old profile data, thinking the update failed. This is a quick way to get your app uninstalled. - We are not observing the
repo.user
flow, so any background updates (by our code) to the cached user data are ignored.
2. Inefficient Loading:
Data (user and articles) is loaded sequentially (refreshUser
completes before getArticles
starts). This makes the initial loading time longer than necessary. While you could use async {}
to parallelize, this often adds complexity, race conditions during data mapping, and more boilerplate.
3. Manual State Management Issues:
Using a raw MutableStateFlow
and manually updating .value
is error-prone.
- It’s easy to introduce subtle bugs related to atomicity and thread safety, especially as logic grows.
- It bypasses the robust state management mechanisms provided by coroutines. As discussed in my other articles, rolling your own state management often leads to problems. We should strive for a single, reliable source of truth for UI state. There must be a better way, right?
4. Eager Loading:
The data loading starts immediately when the ViewModel is initialized, regardless of whether the UI actually needs the data yet or if anyone is observing the state
flow. This can happen with conditional UI logic (e.g., waiting for login) or complex component structures. It’s a waste of resources (CPU, network, battery).
2. Do not roll your own onDataRefresh() callback!
Let’s say we tried to address problem #1 (stale data) and ignored 2 and 3 (because they will only get ticketed by QA in production later).
A common (but flawed) approach is to hook into the system lifecycle and call a “refresh” function:
class HomeViewModel( /* ... */ ) : ViewModel() { // ... init { refreshData() } fun onResume() = refreshData() fun onPullToRefresh() = refreshData() private fun refreshData() = viewModelScope.launch { val user = repo.refreshUser() val articles = repo.getArticles() _state.value = DisplayingFeed(user, articles) } }
This “fix” is just a hack that introduces more problems:
1. The amount of coroutines now grows uncontrollably.
Every time the screen comes back into focus (onResume
), a new refreshData
is launched. If the network is slow and the user navigates back and forth quickly (or interacts with dialogs), you can end up launching many concurrent refresh jobs. These then might:
- Overwrite each other’s results in unpredictable ways.
- Overload the system and network resources.
- Fail due to resource contention.
Manually managing these jobs (e.g., cancelling previous ones) requires complex, error-prone boilerplate involving Job
instances. There must be a simpler way, right?
2. We still did not solve the original problem.
This manual refresh only triggers on lifecycle events or specific user actions. What if the underlying data (like the cached repo.user
) changes due to some other background process unrelated to this screen’s lifecycle? For example, a startup synchronization job might update the user profile after refreshData
has already run. The screen will still show stale data until the next manual refresh
3. Threading problems exacerbated.
As we add more operations (like filtering the feed based on user input), managing concurrency and preventing conflicts with the ongoing refreshData
calls becomes even harder without a proper state management strategy.
4. We likely rolled an incomplete subscription lifecycle implementation. Simply using onResume
is often too frequent for some data and not frequent enough for others. onStart
/onStop
might be better sometimes, but choosing the right lifecycle event and implementing the triggering logic correctly every time adds significant boilerplate, especially if you’re (correctly) avoiding generic BaseViewModel
classes.
5. We leak the update coroutine.
When the user navigates away from the screen, the refreshData
coroutine launched by viewModelScope
keeps running in the background until it completes. If the data loading is resource-intensive and the user quickly moves elsewhere, you’re wasting resources loading data that’s no longer needed immediately. Ideally, this work should be cancelled. This might seem minor for a home screen, but if this pattern is adopted for the entire codebase, the problem will resurface in more significant ways later.
3. Do not observe data sources in the background!
Let’s say QA has reported #1 and #2 from the previous chapter to us. We decided to not add any hacks, and instead correctly subscribe to the user: Flow
in our ViewModel.
This is good, because now we’re able to reactively observe data in our view model. Whenever anything changes the data, we will know about the update. But we made a critical mistake – we collect the flow in the init block.
class HomeViewModel( private val repo: HomeRepository, ): ViewModel() { // ... init { repo.user.onEach { user -> _state.value = DisplayingFeed(user, repo.getArticles()) }.launchIn(viewModelScope) } }
I’ve used launchIn
here to make the code look deceptively simple (and similar to what I’ve seen my colleagues write). The core problem isn’t launchIn
itself, but collecting a flow for the entire lifetime of the viewModelScope
without considering the UI lifecycle. Any mechanism that makes a cold flow – hot (like collect
, launchIn
, stateIn
with SharingStarted.Eagerly
or Lazily
) can lead to this.
The reason the mistake is “critical” is because when we start doing this for all of our screens, we will have leaks that waste resources proportionally to the backstack size, not even mentioning wasting the resources while the app is in background.
Let’s say the user can open the feed page multiple times. The backstack will grow without any limits, and each new ViewModel in the backstack will continue to load and update data. If you have 100 pages in the history, the moment one property of the user object changes, 100 pages will reload their information. This will surface in bad user reviews (poor performance, device heats up, poor battery life) and obscure, untraceable ANRs and OOM crashes, in production. This is almost impossible to discover with manual QA, integration testing, unit testing or during development unless you look for this specific issue.
If you start fixing this on a case by case basis instead, you will need to add caching with the ability to retry, manual lifecycle hooks for every ViewModel, error handling and throttling code, and it will get out of hand quickly. So,
Do NOT collect flows using the
viewModelScope unless you explicitly need the data stream to remain active even when the UI is not visible.
The shoulder case above is rare (maybe <5% of cases) and often indicates an underlying architectural issue or a need for a dedicated background worker.
4. Do not trigger loading from the UI
Suppose you just got frustrated this isn’t working and decided to trigger loading of all information from the UI (in our example — from the composition):
class HomeViewModel(/* ... */) : ViewModel() { // ... suspend fun observeData() = coroutineScope { val feed = repo.getArticles() repo.user.collect { user -> _state.update { it.copy(user = user, feed = feed) } } } } @Composable fun HomeScreen(vm: HomeViewModel) { LaunchedEffect(Unit) { vm.observeData() } // ... }
So in our example we refresh the data until observeData() is cancelled, i.e. while the page is visible.
This approach, including variations like sending “ScreenVisible” events/intents from the UI to the ViewModel to trigger viewModelScope.launch { ... }
, is just another form of manual lifecycle and job management.
The problems with it are:
- We’re back to manually trying to align data loading with the UI lifecycle, which
LaunchedEffect
oronResume
only partially address. - We leak the responsibilities of the ViewModel (loading data) onto the UI layer.
- We now always have to keep track of whether only one consumer is running the
observeData()
. - There is additional burden on the UI to decide when the refreshing is needed. What if the UI doesn’t need the state until, for example user has dismissed an update dialog? Or signed in? All that logic is now on the UI.
- How do you handle retries if
repo.getArticles()
fails? Putting retry logic insideobserveData
is possible, but triggering it or managing its state from the UI becomes awkward.
Part 2: The Right Way to Load and Observe Data
So we have defined what we must not do:
- Don’t observe data streams indefinitely in the backstack/
viewModelScope
without lifecycle awareness. - Don’t rely on manual UI triggers or lifecycle callbacks (
onResume
,LaunchedEffect
) to start core data loading logic in the ViewModel. - Don’t use raw mutable state (
MutableStateFlow
) as your primary state holder without a robust, atomic update mechanism. - Don’t roll your own complex job management for cancellation and restarting.
- Don’t fetch all data sources again if only one changes (unless necessary).
- Don’t use loading logic that isn’t easily cancellable or retryable.
And now here’s what we should actually do:
Combine multiple data sources reactively using flow operators (like
combine) and expose the result as a
StateFlow using
stateIn, configuring it to respect the presence of UI subscribers via
SharingStarted.WhileSubscribed.
An example:
class HomeViewModel( private val repo: HomeRepository, ): ViewModel() { private val articles = flow { emit(repo.getArticles()) }, // 1 val state = combine( // 2 repo.user.distinctUntilChanged(), articles, ) { user, feed -> DisplayingFeed(user, feed) }.stateIn( // 3 scope = viewModelScope, initialValue = HomeState.Loading started = SharingStarted.WhileSubscribed( // 4 stopTimeoutMillis = 1.seconds, // 5 replayExpirationMillis = 9.seconds, // 6 ), ) }
1. We wrap the repo.getArticles()
suspend function call in a flow { }
builder. This creates a cold flow – it doesn’t do anything until collected. We can also apply any mapping operators to individual flows instead of during state assembly, significantly reducing wasted work.
2. The combine
operator:
- Collects all provided flows (
repo.user
,articles
) concurrently. - As soon as all flows have emitted at least one value, invokes the transformation lambda (
{ user, articles -> ... }
) with the latest value from each flow. - Whenever any of the input flows emits a new value later,
combine
re-runs the lambda with the new value and the most recent (cached) values from the other flows, producing an updatedHomeState
.
3. We call the stateIn
operator to convert our cold flow to a hot flow, mainly, because we want to have a value
which can be used on the UI to render it, and to cache and reuse the result of the operator. We will produce the parent flow in the view model scope, but…
4. We will only collect the parent flow WhileSubscribed
. When first subscriber appears, the stateIn
will trigger collection of the combine
result flow, which in turn will trigger all of our data sources. Furthermore, we configure the WhileSubscribed
such that…
5. We stop and cancel the collection of the flow after 1 second (or other reasonable small delay). The 1 second is just an arbitrary value that is roughly equivalent to how long a configuration change can take on Android worst-case. We need to do that because on Android specifically, the UI will briefly unsubscribe while the configuration changes sometimes, and we don’t want to waste resources because of that.
6. Additionally, we configure a replayExpirationMillis
, which is how long a value that was computed last time will be valid for our UI, if it needs to resubscribe. If that time expires, the state will go to the initialValue
again. This isn’t the same as stopTimeout
, as an expiring stopTimeout
will cause the combine
to re-trigger all of the flows regardless of the replay, but a valid replay will also provide the last emitted value to subscribers while that re-trigger is happening (instead of the initialValue
). That number excludes stopTimeout
, so I subtracted 1 second from my desired ten. Sometimes, you may want this to be long or infinite depending on the desired UX.
Here’s why this code has none of the problems discussed above:
- Lazy & On-Demand: Data loading (
articles
) only starts when the UI observes thestate
.repo.user
is only observed while the UI is subscribed. No work is done if the UI isn’t interested. - Always Up-to-Date:
combine
ensures that changes in any underlying data source (repo.user
) automatically trigger a state update. - Parallel Loading:
combine
collects its input flows concurrently.repo.user
observation starts, andarticlesFlow
execution starts in parallel. The firstDisplayingFeed
state is emitted as soon as both have produced a value. - Atomic & Safe Updates: The coroutine machinery handles the concurrency, atomicity, and thread safety within
combine
andstateIn
. We don’t need manual synchronization in the transformation lambda. - Single Upstream Collector:
stateIn
ensures the upstreamcombine
flow is collected only once, regardless of how many UI collectors observe the finalstate
. No risk of uncontrolled coroutine proliferation. - Lifecycle-Aware & Cancellable:
SharingStarted.WhileSubscribed
automatically cancels the upstream collection (including thearticles
network call) when the UI is no longer observing (after the timeout), preventing leaked work and saving resources. - Decoupled: The ViewModel focuses purely on defining the state based on data sources. The UI simply collects the
state
using lifecycle-aware collectors (likecollectAsStateWithLifecycle()
in Compose) without needing to know how the state is produced or trigger anything. - Retryable: You can easily add retry logic to the individual flows before they enter the
combine
operator (e.g., using theretry
operator onarticles
) without complicating the overall structure.
But what if I have transient data that I want to update manually?
Then do NOT do this:
class HomeViewModel @Inject constructor() : ViewModel() { private val screenState = MutableStateFlow(value = ScreenState()) val uiState = screenState .onStart { fetchArticleList() } // ❌ .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), initialValue = screenState.value, // ❌ ) suspend fun fetchArticleList() { delay(timeMillis = 2000L) screenState.update { state -> state.copy( text = "Fetch Data ${state.counter}", counter = state.counter + 1 ) } } }
This erroneous code is copied directly from another article on loading initial data.
Problems with the code:
- Double dispatching. We convert a hot flow to a cold flow using
onStart
and then back to a hot flow. At a minimum, it’s a wasted computation, but this code also doesn’t behave correctly, as theWhileSubscribed
option has no effect on a flow that is backed by a hot flow already. TheonStart
operator is invoked eagerly because the flow is hot. onStart
isn’t the correct operator to use. I already explained how custom callbacks make our code fragile and non-thread-safe.
The code above rolls its own mutable state, and uses double conversion with a trigger to load the initial data, but most importantly, it uses a pattern of “amending” the transient state with the source data. We should do the opposite by amending the data source state with the transient state, like this:
data class UserInput( val searchQuery: String? = null, ) sealed interface HomeState { // ... data class DisplayingArticles( val input: UserInput, // ... ) } class HomeViewModel(/* .. */) : ViewModel() { // ... private val input = MutableStateFlow(UserInput()) val state = combine( repo.user, articles, input, // <- ! using our input ) { user, feed, input -> DisplayingFeed(input, user, feed) }.stateIn( / * ... */ ) fun onSearchQueryChanged(value: String) = input.update { it.copy(searchQuery = value) } }
This way:
- Transient state (
_input
) is managed separately and updated atomically usingupdate
. - The main
state
is still derived reactively bycombine
and gets all the benefits ofstateIn
.
But what if I’m using MVI?
Well, this is a complicated one. With MVI, we have to use a single, mutable state as the source of truth. It’s one of the “flaws” of MVI that proponents of MVVM often appeal to. Mutable state of MVI has its advantages, but here we have to pay the price. But don’t worry, it’s possible to solve this.
We just need to implement logic similar to how WhileSubscribed
works under the hood: it tracks the number of subscribers, and when the number of them drops to 0, it dispatches a special command to the flow to cancel the collection of the upstream.
I thought about this for a while and devised the following extension functions that should behave similarly:
suspend inline fun MutableSharedFlow<*>.whileSubscribed( stopDelay: Duration = 1.seconds, minSubscribers: Int = 1, crossinline action: suspend () -> Unit ) = subscriptionCount // 1 .map { it >= minSubscribers } // 2 .dropWhile { !it } // 3 .debounce { if (it) Duration.ZERO else stopDelay } // 4 .distinctUntilChanged() // 5 .collectLatest { if (it) action() } // 6 inline fun MutableSharedFlow<*>.whileSubscribed( scope: CoroutineScope, stopDelay: Duration = 1.seconds, minSubscribers: Int = 1, crossinline action: suspend () -> Unit ) = scope.launch(start = CoroutineStart.UNDISPATCHED) { whileSubscribed(stopDelay, minSubscribers, action) }
Let’s explain it line by line:
- Each
MutableSharedFlow
(which StateFlow is) has a separate flowsubscriptionCount
, we use it… - To map to whether we have any subscribers that satisfy criteria,..
- Then wait until that condition becomes true for the first time…
- Then if the condition became true, we immediately proceed, otherwise wait
stopDelay
to see if the subscribers appear again shortly… - Then filter out duplicates (that would otherwise repeatedly cancel our flow) of subscription events…
- And then on each change of the subscription count condition, we run
action
if it becametrue
.
Then all you have to do to use this function is:
class HomeViewModel( /* ... */ ) : ViewModel() { // ... init { _state.whileSubscribed(viewModelScope) { combine( repo.user, articles, ) { user, feed -> updateState { produceState(user, feed) } // use a state transaction here to handle transient states }.collect() // important - you are expected to suspend in the block } } }
Keep in mind:
- I personally didn’t test or use this function in production
- It handles only subscriptions to the state. If you have a separate channel for side effects, they won’t be picked up and you need to amend this implementation
- It doesn’t have the state-resetting behavior of
WhileSubscribed
(generally not desired with MVI). If you need it, a simple addition to thecollect
code can give you that. - You still have to correctly subscribe to the state with lifecycle awareness using something like
collectAsStateWithLifecycle
in Compose. - It’s dangerous to use this function without Serialized State Transactions when parallel updates are involved (unless you carefully make the transactions atomic manually).
- You are expected to suspend in the
action
block (instead of usingviewModelScope
) in order to play along with our cancellation policy.
Job Offers
Conclusion: Don’t Reinvent the Wheel
If you are using plain simple MVVM(+), lucky you! Just don’t reinvent the wheel — use the proven and idiomatic way of loading data and producing state, and you will be good for the most part!
And if at some point you decided that you want safer state management or some of the features of MVI, and all that setup sounds complicated, you’re right. There’s just too much stuff to keep in mind.
When people feel like they need something more robust than MVVM, they often roll their own “in-house MVI implementation”, but when it comes to correctly loading and observing data, handling side effects or managing state, 95% of those “implementations” are flawed in one way or another.
It only sounds simple on the surface:
“I just need a flow for side effects and a state flow for states and then to send intents, right?”
Not so much, as you see. In my opinion, that’s why architecture frameworks exist. The benefit of them is that all of the complicated stuff like this is solved, documented, thoroughly tested, benchmarked and verified in production before you commit to any usage of the new code.
You can reinvent a wheel and not depend on any framework — sure, great, “one less library the author may abandon any moment”. But how is your wheel (that your team now has to maintain and fix) better than the community-driven, tested, optimized, documented, polished solution that solved the problems that you never even knew existed, for years?
I made FlowMVI precisely because of that — I was tired of seeing me and my team make the same mistakes over, and over, and over. I spent 2 years polishing the framework so that the entirety of this article is reduced to this code:
val store = store(HomeState.Loading) { val articles by retry { repo.getArticles() } whileSubscribed { combine(repo.user, articles) { user, feed -> updateState { DisplayingFeed(user, feed, typed<DisplayingFeed>()) } }.collect() } } @Composable fun HomeScreen( /* ... */ ) { val state by store.subscribe() }
The code above follows system, composition, navigation and subscription plifecycle, uses SSTs to update the state in parallel, uses transient state to preserve data, allows to retry computations, resets the state properly on shutdown, and even persists the state to disk as needed.
So let’s solve the “data loading” problem together — if you see yet another article, code sample, implementation, or video on how you should “load the data in ViewModels” that has the problems I mentioned, I will be happy if you send the author the link to this article.
I really want to solve this problem forever and save people countless hours of debugging, testing, and fixing the issues we covered, because it pains me so much when those issues are found too late.
This article was previously published on proandroiddev.com.