Skip to content

Kotlin ViewModel — Android State Management Guide

DodaTech Updated 2026-06-28 10 min read

In this tutorial, you will learn about Kotlin ViewModel. We cover key concepts, practical examples, and best practices to help you master this topic.

Android ViewModel is a lifecycle-aware component that stores and manages UI state across configuration changes like screen rotations, providing persistent data access and coroutine scopes for asynchronous operations.

What You'll Learn

  • Create a ViewModel class and handle lifecycle
  • Use ViewModel with Compose and LiveData
  • Manage UI state with StateFlow and MutableStateFlow
  • Use viewModelScope for coroutines
  • Share data between fragments with shared ViewModel
  • Use SavedStateHandle for process death survival
  • Apply ViewModelFactory for Dependency Injection

Why It Matters

ViewModel solves the fundamental problem of state loss during configuration changes. When the user rotates the phone, the activity is destroyed and recreated. Without ViewModel, the UI loses all state. ViewModel survives this lifecycle event. It also provides a natural home for business logic, separating it from the UI layer. This separation makes code testable and maintainable.

Real-World Use

DodaTech's Android app uses ViewModel for every screen. The malware scanner ViewModel manages scan state, progress, and results across rotations. The settings ViewModel loads and saves preferences asynchronously. Shared ViewModels communicate between the file list and scan results fragments.

Learning Path

flowchart LR
  A[Compose Layout] --> B[ViewModel\nYou are here]
  B --> C[Room Database]
  style B fill:#f90,color:#fff

Basic ViewModel

A ViewModel class extends the ViewModel base class and holds UI state.

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

class CounterViewModel : ViewModel() {
    private val _count = MutableStateFlow(0)
    val count: StateFlow<Int> = _count.asStateFlow()
    
    fun increment() {
        _count.value++
    }
    
    fun reset() {
        _count.value = 0
    }
}

The ViewModel is scoped to the activity or fragment. It survives configuration changes automatically.

Using ViewModel in an Activity

Access the ViewModel using the viewModels() delegate.

import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.lifecycle.viewmodel.compose.viewModel

class CounterActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            CounterScreen()
        }
    }
}

@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
    val count by viewModel.count.collectAsState()
    
    Column(
        modifier = Modifier.fillMaxSize().padding(32.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(
            text = "Count: $count",
            fontSize = 40.sp,
            fontWeight = FontWeight.Bold
        )
        
        Spacer(modifier = Modifier.height(24.dp))
        
        Button(onClick = { viewModel.increment() }) {
            Text("Increment", fontSize = 18.sp)
        }
        
        Spacer(modifier = Modifier.height(8.dp))
        
        OutlinedButton(onClick = { viewModel.reset() }) {
            Text("Reset")
        }
    }
}

Output: The count persists across screen rotations. Rotating the phone keeps the current count.

UI State Pattern

For complex UI, use a single sealed data class for the complete screen state.

data class UserProfileUiState(
    val isLoading: Boolean = true,
    val name: String = "",
    val email: String = "",
    val avatarUrl: String? = null,
    val error: String? = null
)

class UserProfileViewModel(
    private val userId: String
) : ViewModel() {
    private val _uiState = MutableStateFlow(UserProfileUiState())
    val uiState: StateFlow<UserProfileUiState> = _uiState.asStateFlow()
    
    init {
        loadProfile()
    }
    
    private fun loadProfile() {
        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(isLoading = true)
            try {
                val profile = UserRepository.getProfile(userId)
                _uiState.value = UserProfileUiState(
                    isLoading = false,
                    name = profile.name,
                    email = profile.email,
                    avatarUrl = profile.avatarUrl
                )
            } catch (e: Exception) {
                _uiState.value = UserProfileUiState(
                    isLoading = false,
                    error = e.message
                )
            }
        }
    }
}

@Composable
fun UserProfileScreen(
    userId: String,
    viewModel: UserProfileViewModel = viewModel(
        key = "profile_$userId",
        factory = ProfileViewModelFactory(userId)
    )
) {
    val state by viewModel.uiState.collectAsState()
    
    when {
        state.isLoading -> CircularProgressIndicator()
        state.error != null -> Text("Error: ${state.error}", color = Color.Red)
        else -> Column(modifier = Modifier.padding(16.dp)) {
            Text(state.name, fontSize = 24.sp, fontWeight = FontWeight.Bold)
            Text(state.email, fontSize = 16.sp, color = Color.Gray)
            state.avatarUrl?.let {
                // Load avatar
            }
        }
    }
}

Output: The UI renders different states based on the ViewModel's uiState. Loading, error, and success states are all represented.

ViewModel with Coroutines

ViewModel provides viewModelScope for launching coroutines that are automatically canceled when the ViewModel is cleared.

class SearchViewModel : ViewModel() {
    private val _searchQuery = MutableStateFlow("")
    val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
    
    private val _searchResults = MutableStateFlow<List<String>>(emptyList())
    val searchResults: StateFlow<List<String>> = _searchResults.asStateFlow()
    
    private val _isSearching = MutableStateFlow(false)
    val isSearching: StateFlow<Boolean> = _isSearching.asStateFlow()
    
    fun updateSearchQuery(query: String) {
        _searchQuery.value = query
        search(query)
    }
    
    private fun search(query: String) {
        if (query.length < 2) {
            _searchResults.value = emptyList()
            return
        }
        
        viewModelScope.launch {
            _isSearching.value = true
            try {
                val results = withContext(Dispatchers.IO) {
                    // Simulated API call
                    delay(500)
                    listOf("Result for '$query' 1", "Result for '$query' 2")
                }
                _searchResults.value = results
            } catch (e: Exception) {
                _searchResults.value = emptyList()
            } finally {
                _isSearching.value = false
            }
        }
    }
}

@Composable
fun SearchScreen(viewModel: SearchViewModel = viewModel()) {
    val query by viewModel.searchQuery.collectAsState()
    val results by viewModel.searchResults.collectAsState()
    val isSearching by viewModel.isSearching.collectAsState()
    
    Column(modifier = Modifier.padding(16.dp)) {
        OutlinedTextField(
            value = query,
            onValueChange = { viewModel.updateSearchQuery(it) },
            label = { Text("Search") },
            modifier = Modifier.fillMaxWidth(),
            singleLine = true,
            trailingIcon = {
                if (isSearching) {
                    CircularProgressIndicator(modifier = Modifier.size(20.dp))
                }
            }
        )
        
        Spacer(modifier = Modifier.height(16.dp))
        
        LazyColumn {
            items(results) { result ->
                Text(
                    result,
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(vertical = 8.dp)
                )
            }
        }
    }
}

Output: The search query updates in real time. Results appear after a simulated delay. The progress indicator shows while searching.

SavedStateHandle

SavedStateHandle preserves data across process death, not just configuration changes.

class FormViewModel(
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {
    private val _name = savedStateHandle.getStateFlow("name", "")
    val name: StateFlow<String> = _name
    
    private val _email = savedStateHandle.getStateFlow("email", "")
    val email: StateFlow<String> = _email
    
    private val _isSubmitted = savedStateHandle.getStateFlow("submitted", false)
    val isSubmitted: StateFlow<Boolean> = _isSubmitted
    
    fun updateName(value: String) {
        savedStateHandle["name"] = value
    }
    
    fun updateEmail(value: String) {
        savedStateHandle["email"] = value
    }
    
    fun submit() {
        savedStateHandle["submitted"] = true
    }
}

// In Compose
@Composable
fun FormScreen() {
    val viewModel: FormViewModel = viewModel()
    val name by viewModel.name.collectAsState()
    val email by viewModel.email.collectAsState()
    val isSubmitted by viewModel.isSubmitted.collectAsState()
    
    Column(modifier = Modifier.padding(16.dp)) {
        if (isSubmitted) {
            Text("Form submitted!", fontSize = 24.sp)
            Text("Name: $name")
            Text("Email: $email")
        } else {
            OutlinedTextField(
                value = name,
                onValueChange = { viewModel.updateName(it) },
                label = { Text("Name") }
            )
            OutlinedTextField(
                value = email,
                onValueChange = { viewModel.updateEmail(it) },
                label = { Text("Email") }
            )
            Button(onClick = { viewModel.submit() }) {
                Text("Submit")
            }
        }
    }
}

Output: Form data survives both configuration changes and process death (when Android kills the app in the background).

Shared ViewModel for Fragments

Multiple fragments in the same activity share a ViewModel scoped to the activity.

class SharedOrderViewModel : ViewModel() {
    private val _selectedItems = MutableStateFlow<List<String>>(emptyList())
    val selectedItems: StateFlow<List<String>> = _selectedItems.asStateFlow()
    
    private val _totalPrice = MutableStateFlow(0.0)
    val totalPrice: StateFlow<Double> = _totalPrice.asStateFlow()
    
    fun addItem(item: String, price: Double) {
        _selectedItems.value = _selectedItems.value + item
        _totalPrice.value += price
    }
    
    fun removeItem(item: String, price: Double) {
        _selectedItems.value = _selectedItems.value - item
        _totalPrice.value -= price
    }
    
    fun clearOrder() {
        _selectedItems.value = emptyList()
        _totalPrice.value = 0.0
    }
}

class MenuFragment : Fragment() {
    private val viewModel: SharedOrderViewModel by activityViewModels()
    
    // Add items to order
    private fun onItemSelected(item: String, price: Double) {
        viewModel.addItem(item, price)
    }
}

class CartFragment : Fragment() {
    private val viewModel: SharedOrderViewModel by activityViewModels()
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        viewModel.selectedItems.observe(viewLifecycleOwner) { items ->
            // Update cart UI
        }
        
        viewModel.totalPrice.observe(viewLifecycleOwner) { total ->
            // Update total UI
        }
    }
}

Output: The MenuFragment adds items to the shared ViewModel. The CartFragment observes changes and updates its UI automatically.

ViewModelFactory for Custom Parameters

Use a ViewModelFactory when the ViewModel needs constructor parameters.

class ProductViewModel(
    private val productId: String,
    private val repository: ProductRepository
) : ViewModel() {
    private val _product = MutableStateFlow<Product?>(null)
    val product: StateFlow<Product?> = _product.asStateFlow()
    
    init {
        loadProduct()
    }
    
    private fun loadProduct() {
        viewModelScope.launch {
            _product.value = repository.getProduct(productId)
        }
    }
}

class ProductViewModelFactory(
    private val productId: String,
    private val repository: ProductRepository = ProductRepository()
) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(ProductViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return ProductViewModel(productId, repository) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

// Usage in Compose
@Composable
fun ProductScreen(productId: String) {
    val viewModel: ProductViewModel = viewModel(
        factory = ProductViewModelFactory(productId)
    )
    val product by viewModel.product.collectAsState()
    
    // Render product UI
}

Output: The ViewModel receives the productId parameter through a Factory, enabling dependency injection for testability.

Common Mistakes

  1. Holding activity references in ViewModel: ViewModels should not hold references to Activity, Context, or View. Use AndroidViewModel if you need the application context, or use Repository patterns.

  2. Exposing mutable state directly: Expose StateFlow as StateFlow (read-only) and keep MutableStateFlow private. This prevents external modification of state.

  3. Creating ViewModel instances with constructor: Always use the ViewModelProvider (viewModels() delegate) to let the framework manage the lifecycle. Manual instantiation loses the configuration-change survival.

  4. Performing long-running operations without viewModelScope: Use viewModelScope.launch for coroutines. Regular GlobalScope or lifecycleScope does not cancel automatically when the ViewModel is cleared.

  5. Not handling process death: ViewModel survives configuration changes but not process death. Use SavedStateHandle for data that must survive both.

  6. Overusing shared ViewModels: Shared ViewModels create tight coupling between fragments. Use sparingly, preferring navigation arguments or callbacks for simple data passing.

Practice Questions

  1. How does ViewModel survive configuration changes?

Answer: ViewModelStore stores ViewModels in the parent Activity or Fragment. When the activity is recreated, the old ViewModelStore is retrieved and its ViewModels are reused instead of creating new ones.

  1. What is the difference between StateFlow and LiveData?

Answer: StateFlow is Kotlin-native, requires an initial value, does not auto-dispose (use viewModelScope for that), and works seamlessly with Compose collectAsState(). LiveData is Java-compatible and auto-disposes on lifecycle stop.

  1. What is SavedStateHandle used for?

Answer: SavedStateHandle preserves data across process death. Data is saved to a Bundle and restored when the process recreates. It survives both configuration changes and process kills.

  1. When would you use a custom ViewModelFactory?

Answer: When the ViewModel has constructor parameters (like a repository or a user ID) that cannot be provided by the default factory.

  1. Challenge: Build a ViewModel-based quiz app. Each question has text, options, and a correct answer. The ViewModel tracks current question index, score, answers, and timer. Handle configuration changes, auto-advance to the next question, and show results at the end.

Answer:

data class Question(
    val text: String,
    val options: List<String>,
    val correctAnswer: Int
)

data class QuizState(
    val currentQuestion: Int = 0,
    val score: Int = 0,
    val answers: List<Int?> = emptyList(),
    val timeRemaining: Int = 30,
    val isFinished: Boolean = false
)

class QuizViewModel(
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {
    private val questions = listOf(
        Question("What is 2+2?", listOf("3", "4", "5", "6"), 1),
        Question("What is the capital of France?", listOf("London", "Berlin", "Paris", "Madrid"), 2),
        Question("What is Kotlin?", listOf("A language", "A database", "A framework", "An OS"), 0)
    )
    
    private val _state = MutableStateFlow(QuizState(
        answers = List(questions.size) { null }
    ))
    val state: StateFlow<QuizState> = _state.asStateFlow()
    
    private var timerJob: Job? = null
    
    init {
        startTimer()
    }
    
    private fun startTimer() {
        timerJob?.cancel()
        timerJob = viewModelScope.launch {
            while (_state.value.timeRemaining > 0 && !_state.value.isFinished) {
                delay(1000)
                _state.value = _state.value.copy(timeRemaining = _state.value.timeRemaining - 1)
            }
            if (_state.value.timeRemaining == 0) {
                advanceQuestion()
            }
        }
    }
    
    fun answerQuestion(optionIndex: Int) {
        val current = _state.value.currentQuestion
        val isCorrect = optionIndex == questions[current].correctAnswer
        val newAnswers = _state.value.answers.toMutableList()
        newAnswers[current] = optionIndex
        
        _state.value = _state.value.copy(
            score = _state.value.score + if (isCorrect) 1 else 0,
            answers = newAnswers
        )
        advanceQuestion()
    }
    
    private fun advanceQuestion() {
        val next = _state.value.currentQuestion + 1
        if (next >= questions.size) {
            _state.value = _state.value.copy(isFinished = true)
        } else {
            _state.value = _state.value.copy(
                currentQuestion = next,
                timeRemaining = 30
            )
            startTimer()
        }
    }
}

Mini Project

Build a todo list app with ViewModel. Requirements:

  • TodoViewModel with StateFlow-based state
  • Add, complete, delete, and filter todos
  • Persistent state with SavedStateHandle
  • Compose UI with LazyColumn
  • ViewModelFactory for repository injection
  • Test the ViewModel with fake repository

This project consolidates all ViewModel patterns in a practical application.

FAQ

What is the difference between viewModel() and activityViewModels()?

viewModel() scopes the ViewModel to the current composable or fragment. activityViewModels() scopes it to the host activity, enabling fragment-to-fragment communication.

Can a ViewModel hold a Context?

Only through AndroidViewModel which provides the application context. Never hold an Activity or View context, as this causes memory leaks.

How do I test a ViewModel?

Use a fake repository, instantiate the ViewModel directly with test parameters, and verify StateFlow values. Use runBlockingTest for coroutine testing.

What happens to a ViewModel when the activity finishes?

ViewModel.onCleared() is called. viewModelScope coroutines are canceled. The ViewModel is garbage collected.

Can I use ViewModel without LiveData or StateFlow?

Yes. Use plain Kotlin properties with callback interfaces. StateFlow and LiveData are convenience patterns for observation.

What's Next

After mastering ViewModel, learn Room database for local persistence. You can also explore Retrofit for network communication.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro