Android ViewModel Patterns: What AI Gets Right (and Wrong)
When you ask an AI to generate Android ViewModel code, you often get something that "works" but violates fundamental architecture principles. As someone who's debugged hundreds of AI-generated Android apps, I've learned to spot the patterns that save your project from disaster.
Let me walk you through what AI typically gets wrong, and how to build rock-solid ViewModels that actually follow Android best practices.
ViewModel Fundamentals: The Foundation
Before we talk about patterns, let's establish what a ViewModel actually is:
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 UserViewModel : ViewModel() {
private val _userName = MutableStateFlow("")
val userName: StateFlow<String> = _userName.asStateFlow()
fun updateUserName(name: String) {
_userName.value = name
}
}
A ViewModel is a lifecycle-aware container that survives configuration changes (like screen rotation). It holds UI state and business logic.
What AI typically generates:
- ViewModels that don't survive rotation
- Public mutable state (exposing MutableStateFlow directly)
- Mixing UI logic with data management
- Synchronous operations that block the UI thread
The StateFlow vs LiveData Decision
AI models were trained on outdated examples. LiveData is still valid, but StateFlow represents the modern Kotlin Coroutines approach.
LiveData (Traditional, Still Valid)
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
class LegacyViewModel : ViewModel() {
private val _userEmail = MutableLiveData<String>()
val userEmail: LiveData<String> = _userEmail
fun loadUser(userId: String) {
_userEmail.value = "user@example.com"
}
}
Pros:
- Built into Jetpack
- Lifecycle-aware by default
- Easy to learn
Cons:
- Not Flow-based (no backpressure handling)
- Harder to compose operations
- Cold flows for each observer
StateFlow (Modern Standard)
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 ModernViewModel : ViewModel() {
private val _userEmail = MutableStateFlow("")
val userEmail: StateFlow<String> = _userEmail.asStateFlow()
fun loadUser(userId: String) {
viewModelScope.launch {
_userEmail.value = fetchUserEmail(userId)
}
}
private suspend fun fetchUserEmail(userId: String): String {
// Network call here
return "user@example.com"
}
}
Pros:
- Hot flow (single instance for all observers)
- Backpressure support
- Composable with other flows
- Suspend function ready
Cons:
- Requires Kotlin Coroutines knowledge
- Slightly more boilerplate
The right choice: Use StateFlow. LiveData is fine for existing projects, but StateFlow is the official recommendation since Android Architecture Components evolved.
Common AI Mistakes and How to Fix Them
Mistake #1: Exposing Mutable State
What AI generates (WRONG):
class BadViewModel : ViewModel() {
val userName = MutableStateFlow("") // Public, mutable!
fun loadUser(id: String) {
userName.value = "Alice"
}
}
Why it's wrong:
- Any code can modify userName directly
- No control over state changes
- Impossible to debug who changed the state
- Violates encapsulation
The fix (RIGHT):
class GoodViewModel : ViewModel() {
private val _userName = MutableStateFlow("")
val userName: StateFlow<String> = _userName.asStateFlow()
fun loadUser(id: String) {
_userName.value = "Alice"
}
}
Now only the ViewModel can modify userName.
Mistake #2: Blocking the Main Thread
What AI generates (WRONG):
class BadViewModel : ViewModel() {
private val _userData = MutableStateFlow<User?>(null)
val userData: StateFlow<User?> = _userData.asStateFlow()
fun loadUser(id: String) {
// BLOCKING! This freezes the UI
val user = fetchUserFromNetwork(id)
_userData.value = user
}
private fun fetchUserFromNetwork(id: String): User {
return Thread.sleep(2000) // Simulating network delay
// ...
}
}
The fix (RIGHT):
class GoodViewModel : ViewModel() {
private val _userData = MutableStateFlow<User?>(null)
val userData: StateFlow<User?> = _userData.asStateFlow()
fun loadUser(id: String) {
viewModelScope.launch {
val user = fetchUserFromNetwork(id)
_userData.value = user
}
}
private suspend fun fetchUserFromNetwork(id: String): User {
delay(2000) // Non-blocking, suspends the coroutine
return User("Alice", "alice@example.com")
}
}
Always use viewModelScope.launch for async operations.
Mistake #3: Not Handling Errors
What AI generates (WRONG):
class BadViewModel : ViewModel() {
private val _userData = MutableStateFlow<User?>(null)
val userData: StateFlow<User?> = _userData.asStateFlow()
fun loadUser(id: String) {
viewModelScope.launch {
val user = fetchUserFromNetwork(id)
_userData.value = user
}
// No error handling! App crashes silently or shows no feedback
}
}
The fix (RIGHT):
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
sealed class UserUiState {
data object Loading : UserUiState()
data class Success(val user: User) : UserUiState()
data class Error(val exception: Exception) : UserUiState()
}
class GoodViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
fun loadUser(id: String) {
viewModelScope.launch {
try {
_uiState.value = UserUiState.Loading
val user = fetchUserFromNetwork(id)
_uiState.value = UserUiState.Success(user)
} catch (e: Exception) {
_uiState.value = UserUiState.Error(e)
}
}
}
}
Now the UI can respond to loading, success, and error states.
Mistake #4: Not Using Data Classes or Sealed Classes
What AI generates (WRONG):
class BadViewModel : ViewModel() {
private val _name = MutableStateFlow("")
private val _email = MutableStateFlow("")
private val _age = MutableStateFlow(0)
// Scattered state across multiple flows
}
The fix (RIGHT):
data class User(
val id: String,
val name: String,
val email: String,
val age: Int
)
class GoodViewModel : ViewModel() {
private val _user = MutableStateFlow<User?>(null)
val user: StateFlow<User?> = _user.asStateFlow()
// Single source of truth
}
Or for UI states:
sealed class UserLoadState {
data object Idle : UserLoadState()
data object Loading : UserLoadState()
data class Loaded(val user: User) : UserLoadState()
data class Error(val message: String) : UserLoadState()
}
The Perfect ViewModel Pattern (Production-Ready)
Here's a template that avoids all the mistakes above:
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
data class User(
val id: String,
val name: String,
val email: String
)
sealed class UserScreenState {
data object Idle : UserScreenState()
data object Loading : UserScreenState()
data class Success(val user: User) : UserScreenState()
data class Error(val message: String) : UserScreenState()
}
class UserViewModel(
private val userRepository: UserRepository
) : ViewModel() {
// Private mutable state
private val _screenState = MutableStateFlow<UserScreenState>(UserScreenState.Idle)
// Public immutable state
val screenState: StateFlow<UserScreenState> = _screenState.asStateFlow()
// Public actions
fun loadUser(userId: String) {
viewModelScope.launch {
try {
_screenState.value = UserScreenState.Loading
val user = userRepository.getUser(userId)
_screenState.value = UserScreenState.Success(user)
} catch (e: Exception) {
_screenState.value = UserScreenState.Error(e.message ?: "Unknown error")
}
}
}
fun clearState() {
_screenState.value = UserScreenState.Idle
}
}
In Your Compose UI:
@Composable
fun UserScreen(viewModel: UserViewModel = hiltViewModel()) {
val screenState by viewModel.screenState.collectAsState()
LaunchedEffect(Unit) {
viewModel.loadUser("user123")
}
when (val state = screenState) {
UserScreenState.Idle -> Text("Ready")
UserScreenState.Loading -> CircularProgressIndicator()
is UserScreenState.Success -> Text("Hello, ${state.user.name}")
is UserScreenState.Error -> Text("Error: ${state.message}")
}
}
Key Takeaways
- Always expose immutable StateFlow, never MutableStateFlow
- Use viewModelScope.launch for async operations
- Handle errors explicitly with sealed classes
- Model UI state with sealed classes, not scattered booleans
- Avoid blocking operations in the main thread
- Use dependency injection (Hilt) for repositories
- Test your ViewModels with Turbine or similar flow testing libraries
What's Next?
AI-generated ViewModel code often works for simple cases, but falls apart under real-world conditions (network errors, configuration changes, memory pressure). The patterns above scale from hobby projects to production apps handling millions of users.
All 8 templates use proper ViewModel + StateFlow architecture. Get them at https://myougatheaxo.gumroad.com
Keep building. Keep learning.
Top comments (0)