Mastering ViewModel and Lifecycle in Jetpack Compose

Below is a comprehensive, in-depth article on using ViewModel and understanding the Lifecycle in Jetpack Compose. This article assumes you have some familiarity with the Android architecture components and Jetpack Compose. If you’re new to Compose, don’t worry — you’ll still gain valuable insights into how ViewModels and lifecycles work in a typical Compose-based application. Let’s dive in!
Table of Contents
1. Introduction
2. What Is a ViewModel and Why Use It?
3. Jetpack Compose and Its Relationship to the Android Lifecycle
4. Using ViewModel in Jetpack Compose
. Creating a ViewModel
. Accessing the ViewModel in Composable Functions
. Observing State from the ViewModel
5. Lifecycle-Aware Components in Compose
6. State Management in Jetpack Compose
. remember
. rememberSaveable
7. Practical Examples
. Example 1: Simple Counter
. Example 2: Network Data Fetch with ViewModel
8. Best Practices
9. Conclusion
1. Introduction
Jetpack Compose modernizes Android UI development by offering a declarative way to create reactive UIs with less boilerplate. One of the core ideas behind Compose is that the UI is generated from state, meaning you can create composable functions that react automatically to changes in the underlying data. When the data changes, your UI re-composes itself to reflect the new state.
As with traditional Android UI development, managing data properly across configuration changes (such as screen rotations) and other lifecycle events is crucial. That’s where the ViewModel comes in. ViewModels can hold data that survives configuration changes and helps to keep logic out of your UI.
In this article, we’ll explore how to properly structure your app using ViewModel in a Jetpack Compose environment, covering the main pieces of how composables fit into the Android lifecycle, how to integrate and observe ViewModel state, and ultimately how to create lifecycle-aware components.
2. What Is a ViewModel and Why Use It?
A ViewModel is part of the Android Architecture Components. Its primary goal is to store and manage UI-related data in a lifecycle-conscious way. It is tied to a lifecycle — usually an Activity or a Fragment — but will survive configuration changes like screen rotations or UI rebuilds.
Key characteristics:
• Survives configuration changes: The data in the ViewModel remains intact even after an Activity or Fragment is recreated (e.g., due to a device rotation).
• Holds UI logic and data: The ViewModel is responsible for preparing data for the UI. Instead of business logic scattered across multiple classes, you centralize it in the ViewModel, which helps with maintenance.
• Lifecycle-aware: The Android system automatically clears the ViewModel when its associated lifecycle is finished. This ensures the data is cleared only when it’s no longer needed.
In a Compose application, you can easily integrate ViewModels to ensure your UI is always reflecting the most up-to-date state, without manually handling lifecycle events.
3. Jetpack Compose and Its Relationship to the Android Lifecycle
Jetpack Compose is designed to be lifecycle-aware. Each composable function is triggered by the Compose runtime, which listens for changes in state. The runtime will invoke re-composition when your state changes, ensuring your UI automatically updates.
A common question is how composables fit into the traditional Android lifecycle of onCreate(), onStart(), onResume(), etc. While composables themselves do not have these methods, the Activity or Fragment hosting the composables still do. You can think of composables as pure functions that react to data changes, but the underlying structure still lives within the Android lifecycle.
4. Using ViewModel in Jetpack Compose
- Creating a ViewModel
In its simplest form, you can create a ViewModel by extending the ViewModel class:
import androidx.lifecycle.ViewModel
class MyViewModel : ViewModel() {
// UI data and logic
private var _counter = 0
val counter: Int
get() = _counter
fun incrementCounter() {
_counter++
}
}
• MyViewModel holds a simple integer _counter.
• We expose counter as a read-only property, and also provide a function incrementCounter() to modify the value.
Accessing the ViewModel in Composable Functions
In a traditional Activity, you might use ViewModelProvider to get your ViewModel. In Jetpack Compose, you can do the same with the built-in viewModel() function provided by the androidx.lifecycle and androidx.compose.runtime libraries.
@Composable
fun MyScreen(
myViewModel: MyViewModel = viewModel()
) {
// Retrieve data from ViewModel
val count = myViewModel.counter
Column {
Text("Count: $count")
Button(onClick = { myViewModel.incrementCounter() }) {
Text("Increment")
}
}
}
Here’s what happens:
1. myViewModel: MyViewModel = viewModel() automatically retrieves an instance of MyViewModel.
2. When the user clicks the button, incrementCounter() is called, updating _counter.
3. Compose re-composes the UI and Text(“Count: $count”) displays the new value.
Why does it survive configuration changes? Because under the hood, viewModel() uses ViewModelProvider that’s scoped to the Activity. As the configuration changes, your composable is reconstructed, but the MyViewModel remains the same instance — preserving its state.
Observing State from the ViewModel
Most real applications use LiveData or a StateFlow (Kotlin Coroutines) to expose data changes. In Compose, a more idiomatic approach is using Kotlin State or MutableState, or flows that can be collected in composable functions.
Using LiveData
class MyViewModel : ViewModel() {
private val _text = MutableLiveData("Initial value")
val text: LiveData<String> = _text
fun updateText(newText: String) {
_text.value = newText
}
}
In your composable:
@Composable
fun MyScreen(myViewModel: MyViewModel = viewModel()) {
val textValue by myViewModel.text.observeAsState("")
Text(textValue)
// ...
}
- observeAsState() converts the LiveData into Compose State, so your UI can easily recompose whenever textValuechanges.
Using StateFlow or SharedFlow
class MyViewModel : ViewModel() {
private val _uiState = MutableStateFlow("Initial state")
val uiState: StateFlow<String> = _uiState
fun updateState(newValue: String) {
_uiState.value = newValue
}
}
In your composable:
@Composable
fun MyScreen(myViewModel: MyViewModel = viewModel()) {
val textValue by myViewModel.uiState.collectAsState()
Text(textValue)
// ...
}
collectAsState() is how you collect StateFlow or Flow in Compose.
5. Lifecycle-Aware Components in Compose
Compose’s lifecycle awareness ensures that when your Activity or Fragment moves through various lifecycle states, your composables’ re-compositions are managed accordingly. However, if you’re using side effects such as LaunchedEffect, DisposableEffect, or any flow collection, you need to ensure they are scoped correctly.
For example, LaunchedEffect runs a coroutine the first time your composable is placed in composition, and can be canceled if the composable leaves the composition. This means you can perform tasks like data loading from the ViewModel without memory leaks, as the system will handle cancellation automatically when the composable is no longer visible.
6. State Management in Jetpack Compose
Proper state management in Compose is key to building scalable UIs. Understanding where and how to store state is essential. While the ViewModel is typically responsible for the application or screen scope data, Compose provides local ways to handle state that only lives during the UI composition.
remember
remember is a function in Compose that remembers a single object across recompositions in the same composition lifecycle. However, the data is lost if the composable leaves the composition or if the user rotates the device (i.e., when the Activity is recreated).
@Composable
fun RememberExample() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Count: $count")
}
}
rememberSaveable
rememberSaveable is similar to remember, but the stored data will survive process death and configuration changes if it’s something that can be serialized (like a primitive type or a Parcelable). Under the hood, it uses the saved instance state mechanism of Android.
@Composable
fun RememberSaveableExample() {
var count by rememberSaveable { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Count: $count")
}
}
However, rememberSaveable should still be used for simple, UI-specific states. If the value you’re saving is more than just UI state and ties into business logic or data, consider pushing it to a ViewModel.
7. Practical Examples
Example 1: Simple Counter with ViewModel
Let’s combine everything into a straightforward example. Suppose you have an app that has a counter. You want to retain the counter’s value on configuration changes.
ViewModel:
class CounterViewModel : ViewModel() {
private val _count = MutableStateFlow(0)
val count: StateFlow<Int> = _count
fun increment() {
_count.value = _count.value + 1
}
override fun onCleared() {
super.onCleared()
// Cleanup, if any
}
}
Composable:
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
val currentCount by viewModel.count.collectAsState()
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Count: $currentCount", style = MaterialTheme.typography.h4)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { viewModel.increment() },
modifier = Modifier.fillMaxWidth()
) {
Text("Increment")
}
}
}
• Explanation: Every time viewModel.increment() is called, the _count flows a new value. Compose observes this with collectAsState(), updating the UI as needed. Because we’re using a ViewModel, the value doesn’t reset on rotation.
Example 2: Network Data Fetch with ViewModel
In this scenario, let’s say we want to fetch a list of items from the network (or any data source) and display it on the screen. The ViewModel can handle the network request, and the composable observes the results.
ViewModel (using a Coroutine to fetch data):
class ItemsViewModel : ViewModel() {
private val _uiState = MutableStateFlow<List<String>>(emptyList())
val uiState: StateFlow<List<String>> = _uiState
init {
fetchData()
}
private fun fetchData() {
viewModelScope.launch {
// Simulate network delay
delay(2000)
val dataFromNetwork = listOf("Item1", "Item2", "Item3")
_uiState.value = dataFromNetwork
}
}
}
Composable:
@Composable
fun ItemsScreen(itemsViewModel: ItemsViewModel = viewModel()) {
val items by itemsViewModel.uiState.collectAsState()
if (items.isEmpty()) {
Text("Loading data...")
} else {
LazyColumn {
items(items) { item ->
Text(text = item, modifier = Modifier.padding(8.dp))
Divider()
}
}
}
}
• We are using a LazyColumn to display the list of items.
• The “Loading data…” text is shown while the items list is empty (which might be the default state).
• Once data is fetched, _uiState updates, and your UI re-composes with the new list.
- Even if the user rotates the device during loading, the ViewModel does not lose its state or cancel the network request unless the Activity is destroyed (for example, navigating away from the screen completely).
8. Best Practices
1. Keep UI logic in the ViewModel: Use your composables for UI rendering and minor UI-specific logic. Let the ViewModel handle data retrieval, transformations, and overall business logic.
2. Leverage lifecycle-aware APIs: Such as viewModel(), collectAsState(), and observeAsState(). They simplify how your UI observes changes.
3. Use Hilt or other dependency injection frameworks (if needed): This helps to inject repositories or other dependencies into your ViewModel.
4. Don’t overuse rememberSaveable: If data is critical to the application and not just ephemeral UI state, keep it in the ViewModel. rememberSaveable is great for small amounts of local UI data (like the currently entered text in a text field).
5. Clean up resources in ViewModel.onCleared(): If you have any coroutines or external streams, cancel or close them in onCleared() to avoid leaks.
6. Avoid passing ViewModel instances around too much: Create a stable composition hierarchy, let child composables be stateless and rely on callbacks or input states from the parent, or pass the same ViewModel if you must share data across them.
9. Conclusion
Jetpack Compose and ViewModel work hand-in-hand to create robust, declarative UIs that automatically update based on underlying state changes. The lifecycle-awareness built into Compose, combined with the ability of ViewModel to survive configuration changes, leads to clean and stable architectures for your Android apps.
• ViewModel ensures data and business logic are retained and remain accessible across screen rotations and configuration changes.
• Jetpack Compose re-composes your UI whenever the data in your ViewModel changes, creating a reactive and efficient development experience.
• Lifecycle-awareness in Compose allows you to safely perform tasks that should be canceled or restarted when the composable leaves or enters the composition.
When properly combined, you get a powerful toolset for building modern and scalable applications. Use the recommended patterns — keep logic in ViewModel and expose state through flows or LiveData — and let Compose handle the rendering for you. This approach will keep your application code more organized, maintainable, and efficient.
Happy coding with Jetpack Compose and ViewModel!