Blog Infos
Author
Published
Topics
, , , ,
Author
Published

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:

  1. User profile info, backed by a local cache file + network endpoint.
  2. Some news articles for the user to read, from the network, without file caching.

The requirements are:

  1. We must only show up-to-date articles while the user is browsing the page.
  2. 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 collectlaunchInstateIn 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:

  1. We’re back to manually trying to align data loading with the UI lifecycle, which LaunchedEffect or onResume only partially address.
  2. We leak the responsibilities of the ViewModel (loading data) onto the UI layer.
  3. We now always have to keep track of whether only one consumer is running the observeData() .
  4. 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.
  5. How do you handle retries if repo.getArticles() fails? Putting retry logic inside observeData 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 (onResumeLaunchedEffect) 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.userarticles) 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 updated HomeState.

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 the staterepo.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, and articlesFlow execution starts in parallel. The first DisplayingFeed 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 and stateIn. We don’t need manual synchronization in the transformation lambda.
  • Single Upstream Collector: stateIn ensures the upstream combine flow is collected only once, regardless of how many UI collectors observe the final state. No risk of uncontrolled coroutine proliferation.
  • Lifecycle-Aware & Cancellable: SharingStarted.WhileSubscribed automatically cancels the upstream collection (including the articles 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 (like collectAsStateWithLifecycle() 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 the retry operator on articles) 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:

  1. 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 the WhileSubscribed option has no effect on a flow that is backed by a hot flow already. The onStart operator is invoked eagerly because the flow is hot.
  2. 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 using update.
  • The main state is still derived reactively by combine and gets all the benefits of stateIn.
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:

  1. Each MutableSharedFlow (which StateFlow is) has a separate flow subscriptionCount, we use it…
  2. To map to whether we have any subscribers that satisfy criteria,..
  3. Then wait until that condition becomes true for the first time
  4. Then if the condition became true, we immediately proceed, otherwise wait stopDelay to see if the subscribers appear again shortly…
  5. Then filter out duplicates (that would otherwise repeatedly cancel our flow) of subscription events…
  6. And then on each change of the subscription count condition, we run action if it became true.

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:

  1. I personally didn’t test or use this function in production
  2. 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
  3. It doesn’t have the state-resetting behavior of WhileSubscribed (generally not desired with MVI). If you need it, a simple addition to the collect code can give you that.
  4. You still have to correctly subscribe to the state with lifecycle awareness using something like collectAsStateWithLifecycle in Compose.
  5. It’s dangerous to use this function without Serialized State Transactions when parallel updates are involved (unless you carefully make the transactions atomic manually).
  6. You are expected to suspend in the action block (instead of using viewModelScope) in order to play along with our cancellation policy.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

No results found.

Jobs

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.

Menu