DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Android ViewModel Patterns: What AI Gets Right (and Wrong)

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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}")
    }
}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Always expose immutable StateFlow, never MutableStateFlow
  2. Use viewModelScope.launch for async operations
  3. Handle errors explicitly with sealed classes
  4. Model UI state with sealed classes, not scattered booleans
  5. Avoid blocking operations in the main thread
  6. Use dependency injection (Hilt) for repositories
  7. 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)