
Loading Initial Data in LaunchedEffect vs. ViewModel
When initializing or fetching data upon entering a screen, it’s crucial to select the right trigger point for loading the initial data. Given that most data flows are delivered by providers and subscribers, ensuring proper data lifecycle management is essential.
Especially in Jetpack Compose, the lifecycle of a composable function is significantly different from that of an Activity. Additionally, when using the Navigation library for Jetpack Compose, each screen has its own distinct lifecycle, meaning the data lifecycle must align with the specific lifespan of each screen based on the requirements.
This article delves into the fascinating discussion on where to load initial data within composable functions and ViewModels, a topic recently raised on Dove Letter. Dove Letter is a subscription repository where you can learn, discuss, and share new insights about Android and Kotlin. If you’re interested in joining, be sure to check out “Learn Kotlin and Android With Dove Letter.”
There’s a follow-up post titled ”Loading Initial Data on Android Part 2: Clear All Your Doubts.” If you want to dive deeper into this topic, be sure to check out that article as well.
Where to Load Initial Data: LaunchedEffect vs. ViewModel.init()
One of the most commonly debated approaches is whether to load initial data in a Composable’s LaunchedEffect
or within ViewModel.init()
. If you’ve explored much of the official Android documentation, you’ll notice they often recommend loading data within ViewModel.init()
, as seen in both the documentation and the architecture-samples GitHub repository.
I was curious about this, so I created a poll to see how the Android community typically prefers to load initial data. Here’s what the results showed:

As you can see from the poll, the majority of people prefer to load initial data inside ViewModel.init()
. One of the Android community members provided a compelling explanation for why ViewModel.init()
is a better choice compared to relying on composable functions like LaunchedEffect
.

This perspective is compelling because the UI layer is primarily a visual representation, so it’s important to separate concerns rather than relying on LaunchedEffect
for managing data initialization.
On the other hand, Dove Letter subscribers offered a different perspective, highlighting why LaunchedEffect
might be a better option than ViewModel.init()
, particularly regarding the flexibility of function calls and ease of unit testing.

This perspective is also compelling, particularly because it emphasizes the flexibility and ease of unit testing that LaunchedEffect
offers compared to ViewModel.init()
. Relying on the event-triggered-based initialization allows the ViewModel to remain focused on its original purpose without additional responsibilities.
This brings us to a common dilemma: What is the best practice for loading initial data?
Both are anti-patterns: Use Lazy Observation
Both of the solutions we’ve explored have their own clear disadvantages. Interestingly, Ian Lake from Google’s Android Toolkit team commented that both approaches are actually considered anti-patterns.

Loading initial data in ViewModel.init()
can introduce side effects during the ViewModel’s creation, straying from its primary purpose and complicating lifecycle management.
On the other hand, initializing data within LaunchedEffect
in Jetpack Compose risks being re-triggered with each initial composition of the screen. If the lifecycle of the composable function differs from that of the ViewModel, it can result in unintended behavior and disrupt the expected data flow.
So, what’s the best practice for initialization? Ian Lake recommends using cold flows for the lazy initialization. Alternatively, you can leverage hot flows like StateFlow
or SharedFlow
, created by combining a Flow
with stateIn
or shareIn
, and using SharingStarted.WhileSubscribed
as the started
parameter to manage flow lifecycle efficiently. Using SharingStarted.WhileSubscribed
ensures that the initial data is preserved across configuration changes, providing a more reliable approach to managing state.
Additionally, you should subscribe to these flows using collectAsStateWithLifecycle in Jetpack Compose. This approach allows you to control when sharing starts and ensures data is fetched lazily when a subscription occurs from the UI layer.
Best Practices for the Lazy Observation
You can find examples of this approach in the Pokedex-Compose project, as demonstrated in the code snippet below:
In the example above, the hot flow (StateFlow
) is created using the stateIn
method combined with SharingStarted.WhileSubscribed
. This ensures that sharing begins when the first subscriber appears and stops immediately when the last subscriber disappears, based on the specified stopTimeoutMillis
parameter.
As a result, the hot flows will start emitting values as soon as the first subscriber appears from the UI layer, ensuring that initial data is loaded only when the UI actually needs it. This approach prevents unnecessary data fetching when it’s not required and stops emitting values when the last subscriber disappears.
But Where Did the 5_000
Come From?
You might still be curious about why the specific value of 5_000
was chosen for the stopTimeoutMillis
parameter rather than another number like 7_000
or 10_000
. The reasoning behind this choice can be found in the official Android documentation, which discusses the ANR (Application Not Responding) timeout threshold.
It explains that when the UI thread of an Android app is blocked for too long, an “Application Not Responding” (ANR) error is triggered. Specifically, an ANR is triggered if your app fails to respond to an input event, such as a key press or screen touch, within 5 seconds.
So, if the last subscriber disappears for more than 5 seconds, it means the timeout has already been exceeded, and the data flow can no longer affect your UI layer. At that point, either the UI no longer needs to be rendered, or an ANR may have already occurred. That makes sense, right?
However, if you find it cumbersome to repeatedly write the same stateIn
boilerplate code with the exact 5_000
timeout, you can simplify your code by creating an extension function with Context Receivers, as shown below:
Ian Lake also mentioned that the 5_000
timeout is precisely aligned with the ANR deadline, as you can see in his reply below:

CollectAsStateWithLifecycle vs. CollectAsState
Another important topic to address is lifecycle management. When using LiveData, the observer is tightly integrated with the Android lifecycle, so unsubscription happens automatically. However, in the world of Flow, lifecycle management must be handled manually to ensure proper unsubscription.
Unlike collectAsState
, collectAsStateWithLifecycle
allows your app to conserve resources when they’re not needed based on the Android lifecycle, such as when the app is in the background. Keeping resources active unnecessarily can negatively impact the user’s device performance and battery life, so collectAsStateWithLifecycle
is the lifecycle-aware version of collectAsState
.
If you examine the collectAsStateWithLifecycle
function under the hood, you will see that it uses the repeatOnLifecycle API, which is the recommended way to consume flows safely in Android following the lifecycle system.
collectAsStateWithLifecycle
allows you to observe a Flow in alignment with the Android lifecycle. It starts collecting when the lifecycle reaches the given minActiveState
(default is onStart
) and stops collecting when the lifecycle reaches onStop
.
This means that flow collection stops to free up app resources when your application is no longer displayed on the screen or is on its way to being destroyed. As a result, you can safely release data layer resources when they are no longer needed, optimizing resource usage.
If you want to learn more about the collectAsStateWithLifecycle
API, check out Consuming flows safely in Jetpack Compose.
Conclusion
We’ve explored the most commonly debated approaches for loading initial data in Jetpack Compose. As we’ve transitioned from the traditional XML system to Jetpack Compose and from LiveData to Flow, our methods have evolved, but the core challenge remains the same: problem-solving. As always, there’s no one-size-fits-all solution. Each project has its own unique demands, and understanding those requirements is key to choosing the most appropriate approach.
If you have any questions or feedback on this article, you can find the author on Twitter @github_skydoves or GitHub. If you’d like to stay updated with the latest information through articles and references, tips with code samples that demonstrate best practices, and news about the overall Android/Kotlin ecosystem, check out ‘Learn Kotlin and Android With Dove Letter’.
As always, happy coding!
— Jaewoong