In Tutorial #5, we learned about remember and mutableStateOf. They work great for simple state. But they have a big problem — when your app needs to load data from a database or API, where does that logic go?

Not in the Composable. Composables are for UI, not business logic.

That is what ViewModel is for.

What is ViewModel?

ViewModel is a class that holds your screen’s data and logic. It survives things that destroy and recreate your Composable — like screen rotation or system theme changes.

Think of it this way:

ComposableViewModel
PurposeShow the UIHold data and logic
Survives rotationNo (redraws)Yes
Survives process deathNoNo (use SavedStateHandle)
Knows about UIYesNo
LifecycleCreated/destroyed with screenLives as long as the screen exists

The rule: UI code goes in Composables. Everything else goes in ViewModel.

Why Not Just Use remember?

// This works... until it doesn't
@Composable
fun UserListScreen() {
    var users by remember { mutableStateOf<List<User>>(emptyList()) }
    var isLoading by remember { mutableStateOf(true) }

    LaunchedEffect(Unit) {
        isLoading = true
        users = api.getUsers()  // API call inside a Composable?
        isLoading = false
    }

    // Show users...
}

Problems with this approach:

  1. API call restarts on every rotationremember doesn’t survive configuration changes
  2. Logic mixed with UI — hard to test, hard to maintain
  3. No separation — the same function handles data loading AND displaying

ViewModel fixes all three.

Your First ViewModel

Step 1: Create the ViewModel

class UserListViewModel : ViewModel() {

    // StateFlow holds the data — Compose observes it
    private val _users = MutableStateFlow<List<User>>(emptyList())
    val users: StateFlow<List<User>> = _users.asStateFlow()

    private val _isLoading = MutableStateFlow(true)
    val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()

    init {
        // Load data when ViewModel is created (once, not on every rotation)
        loadUsers()
    }

    private fun loadUsers() {
        viewModelScope.launch {
            _isLoading.value = true
            _users.value = listOf(
                User("1", "Alex", "alex@example.com"),
                User("2", "Sam", "sam@example.com"),
                User("3", "Jordan", "jordan@example.com"),
            )
            _isLoading.value = false
        }
    }

    fun deleteUser(userId: String) {
        _users.value = _users.value.filter { it.id != userId }
    }
}

data class User(val id: String, val name: String, val email: String)

Step 2: Connect ViewModel to Compose

@Composable
fun UserListScreen(viewModel: UserListViewModel = viewModel()) {
    // collectAsStateWithLifecycle is the recommended way to observe StateFlow
    // It stops collecting when the app is in the background (saves resources)
    // Requires: implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.1")
    val users by viewModel.users.collectAsStateWithLifecycle()
    val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()

    if (isLoading) {
        // Show loading spinner
        Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
            CircularProgressIndicator()
        }
    } else {
        // Show user list
        LazyColumn {
            items(users, key = { it.id }) { user ->
                UserRow(
                    user = user,
                    onDelete = { viewModel.deleteUser(user.id) }
                )
            }
        }
    }
}

That’s it. The ViewModel loads data once. Rotation doesn’t restart the load. The UI just observes and displays.

StateFlow vs mutableStateOf

Both hold state. When should you use which?

mutableStateOfStateFlow
WhereInside ComposablesInside ViewModels
Observe in ComposeDirect (by state)collectAsState()
Thread safeNo (main thread only)Yes (any thread)
Coroutine friendlyNoYes
Use forUI-only state (toggle, animation)Data from ViewModel

Simple rule: Use mutableStateOf with remember inside Composables for UI state. Use StateFlow inside ViewModels for data state.

Instead of having multiple StateFlows, combine everything into one state class. This is the same idea from the MVI tutorial (#10):

// One data class holds everything the screen needs
data class UserListState(
    val users: List<User> = emptyList(),
    val isLoading: Boolean = true,
    val error: String? = null,
    val searchQuery: String = ""
)

class UserListViewModel : ViewModel() {

    private val _state = MutableStateFlow(UserListState())
    val state: StateFlow<UserListState> = _state.asStateFlow()

    init {
        loadUsers()
    }

    private fun loadUsers() {
        viewModelScope.launch {
            _state.update { it.copy(isLoading = true, error = null) }

            try {
                val users = listOf(
                    User("1", "Alex", "alex@example.com"),
                    User("2", "Sam", "sam@example.com"),
                    User("3", "Jordan", "jordan@example.com"),
                    User("4", "Taylor", "taylor@example.com"),
                    User("5", "Morgan", "morgan@example.com"),
                )
                _state.update { it.copy(users = users, isLoading = false) }
            } catch (e: Exception) {
                _state.update { it.copy(error = e.message, isLoading = false) }
            }
        }
    }

    fun onSearchQueryChange(query: String) {
        _state.update { it.copy(searchQuery = query) }
    }

    fun deleteUser(userId: String) {
        _state.update { currentState ->
            currentState.copy(
                users = currentState.users.filter { it.id != userId }
            )
        }
    }

    fun retry() {
        loadUsers()
    }
}

The Composable:

@Composable
fun UserListScreen(viewModel: UserListViewModel = viewModel()) {
    val state by viewModel.state.collectAsStateWithLifecycle()

    Column(modifier = Modifier.fillMaxSize()) {
        // Search bar
        OutlinedTextField(
            value = state.searchQuery,
            onValueChange = { viewModel.onSearchQueryChange(it) },
            label = { Text("Search") },
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp)
        )

        when {
            state.isLoading -> {
                Box(
                    modifier = Modifier.fillMaxSize(),
                    contentAlignment = Alignment.Center
                ) {
                    CircularProgressIndicator()
                }
            }
            state.error != null -> {
                Column(
                    modifier = Modifier.fillMaxSize(),
                    verticalArrangement = Arrangement.Center,
                    horizontalAlignment = Alignment.CenterHorizontally
                ) {
                    Text("Error: ${state.error}")
                    Button(onClick = { viewModel.retry() }) {
                        Text("Retry")
                    }
                }
            }
            else -> {
                // Filter users based on search query
                val filteredUsers = state.users.filter {
                    it.name.contains(state.searchQuery, ignoreCase = true)
                }

                LazyColumn {
                    items(filteredUsers, key = { it.id }) { user ->
                        UserRow(
                            user = user,
                            onDelete = { viewModel.deleteUser(user.id) }
                        )
                    }
                }
            }
        }
    }
}

One state object makes your screen predictable. You always know what data is available by looking at the state class.

ViewModel with Navigation Arguments

When you navigate to a screen with arguments (from Tutorial #8), the ViewModel can read them using SavedStateHandle:

@Serializable
data class UserProfile(val userId: String)

class UserProfileViewModel(
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    // Read the navigation argument
    private val route = savedStateHandle.toRoute<UserProfile>()
    private val userId = route.userId

    private val _state = MutableStateFlow(ProfileState())
    val state: StateFlow<ProfileState> = _state.asStateFlow()

    init {
        loadProfile(userId)
    }

    private fun loadProfile(id: String) {
        viewModelScope.launch {
            // In a real app, fetch from database or API using the ID
            _state.update {
                it.copy(
                    name = "User $id",
                    email = "$id@example.com",
                    isLoading = false
                )
            }
        }
    }
}

data class ProfileState(
    val name: String = "",
    val email: String = "",
    val isLoading: Boolean = true
)

In the NavHost:

composable<UserProfile> {
    // ViewModel is automatically created with the saved state handle
    val viewModel: UserProfileViewModel = viewModel()
    val state by viewModel.state.collectAsStateWithLifecycle()

    ProfileScreen(state = state)
}

The navigation passes the argument → SavedStateHandle stores it → ViewModel reads it. Clean and type-safe.

viewModelScope and Coroutines

ViewModel has a built-in coroutine scope called viewModelScope. It automatically cancels when the ViewModel is destroyed.

class MyViewModel : ViewModel() {

    fun loadData() {
        // This coroutine automatically cancels when ViewModel is destroyed
        viewModelScope.launch {
            val data = repository.fetchData()  // Suspend function
            _state.update { it.copy(data = data) }
        }
    }

    fun loadMultipleThings() {
        viewModelScope.launch {
            // Run two operations in parallel
            val users = async { repository.getUsers() }
            val posts = async { repository.getPosts() }

            _state.update {
                it.copy(
                    users = users.await(),
                    posts = posts.await()
                )
            }
        }
    }
}

Never use GlobalScope or create your own CoroutineScope in a ViewModel. Always use viewModelScope.

When State Changes in ViewModel vs Composable

Here is a clear guide:

Keep in ViewModel (StateFlow)

  • Data from database or API
  • User list, search results, form data
  • Loading state, error messages
  • Anything that should survive rotation

Keep in Composable (remember)

  • Is the dropdown menu open?
  • Current scroll position
  • Which text field has focus?
  • Animation progress
  • Anything that is OK to reset on rotation

Example: Both Together

@Composable
fun UserListScreen(viewModel: UserListViewModel = viewModel()) {
    // ViewModel state — survives rotation
    val state by viewModel.state.collectAsStateWithLifecycle()

    // UI state — OK to reset on rotation
    var showDeleteDialog by remember { mutableStateOf(false) }
    var selectedUser by remember { mutableStateOf<User?>(null) }

    // ... use both kinds of state
}

Common Mistakes

Mistake 1: Creating ViewModel Manually

// BAD — new instance on every recomposition
val viewModel = UserListViewModel()

// GOOD — Compose manages the lifecycle
val viewModel: UserListViewModel = viewModel()

Mistake 2: Observing StateFlow Without collectAsState

// BAD — value doesn't trigger recomposition
val users = viewModel.users.value

// GOOD — triggers recomposition when value changes
val users by viewModel.users.collectAsState()

Mistake 3: Passing ViewModel to Child Composables

// BAD — child knows about ViewModel
@Composable
fun UserRow(viewModel: UserListViewModel, user: User) { ... }

// GOOD — child receives only what it needs
@Composable
fun UserRow(user: User, onDelete: () -> Unit) { ... }

Mistake 4: Doing UI Work in ViewModel

// BAD — ViewModel should not know about UI
class MyViewModel : ViewModel() {
    fun showToast(context: Context) { // Don't pass Context!
        Toast.makeText(context, "Done", Toast.LENGTH_SHORT).show()
    }
}

// GOOD — ViewModel sends an event, UI handles the display
class MyViewModel : ViewModel() {
    private val _showMessage = MutableStateFlow<String?>(null)
    val showMessage: StateFlow<String?> = _showMessage

    fun doSomething() {
        _showMessage.value = "Done"
    }
}

Quick Reference

ActionCode
Create ViewModelval viewModel: MyViewModel = viewModel()
Expose stateval state: StateFlow<MyState> = _state.asStateFlow()
Update state_state.update { it.copy(loading = true) }
Observe in Composeval state by viewModel.state.collectAsStateWithLifecycle()
Launch coroutineviewModelScope.launch { ... }
Read nav argumentsavedStateHandle.toRoute<MyRoute>()

Result

Here is what the app looks like when you run the code from this tutorial:

Light ModeDark Mode
Tutorial 9 LightTutorial 9 Dark

Source Code

The complete working code for this tutorial is on GitHub:

View source code on GitHub →

What’s Next?

You now have ViewModel and MVI (Tutorial #10) covered. In the next tutorial, we will learn about Side EffectsLaunchedEffect, DisposableEffect, and how to safely run code that interacts with the outside world from your Composables.

See you there.