A Jetpack Compose login screen looks simple — but it's one of the easiest places for MVVM architecture to fall apart. You need to manage API calls, input validation, loading states, error messages, and navigation, all without leaking memory, triggering navigation twice after screen rotation, or leaving business logic buried inside a Button's onClick.
I know because my early Activities were 2000+ lines of spaghetti code. MVVM fixed that — not because it's trendy, but because it gives each concern exactly one home.
What this post covers:
- A 4-layer MVVM architecture applied to a real login flow (UI → ViewModel → UseCase → Repository)
- StateFlow for UI state and SharedFlow for one-time navigation events
- Hilt dependency injection wired end-to-end
- Common MVVM mistakes that cause memory leaks and duplicate navigation
Why MVVM Handles Login Screens Better
The 4-layer structure used throughout this post works like this:
- UI (Composable) — renders state, forwards user actions, nothing else
- ViewModel — owns state and emits one-time navigation events
- UseCase — runs validation and business rules before touching the network
- Repository — handles the API call and persists the token locally
Each layer only talks to the one directly below it. Your Composable never touches the Repository. Your ViewModel never calls the API directly.
This is what makes each piece independently testable without spinning up an emulator.
The 4-Layer Structure I Use
After trying different approaches, I've settled on this structure:
┌─────────────────────────────────────┐
│ UI Layer (Composables) │ ← What users see
├─────────────────────────────────────┤
│ ViewModel Layer (State Logic) │ ← Manages UI state
├─────────────────────────────────────┤
│ Domain Layer (Use Cases) │ ← Business rules
├─────────────────────────────────────┤
│ Data Layer (Repository + API/DB) │ ← Where data comes from
└─────────────────────────────────────┘
The rule is simple: each layer only talks to the layer directly below it. UI calls ViewModel, ViewModel calls UseCase, UseCase calls Repository. Never skip layers.
I know some people skip the Domain layer for simple apps, and honestly, that's fine. But I've been burned too many times by "simple" apps that grew. Adding UseCases later is painful.
StateFlow vs SharedFlow vs LiveData - Which One?
This confused me for months. Here's how I think about it now:
StateFlow - For state that the UI needs to observe. Loading spinners, error messages, list data. Use this 90% of the time.
SharedFlow - For one-time events. Navigation, showing a toast, triggering a dialog. Things that should happen once and not repeat when the screen rotates.
LiveData - I don't use it with Compose anymore. It was great with XML views, but StateFlow works better with Compose's reactive model.
mutableStateOf - For local UI state inside a composable. Text field values, checkbox states, stuff that doesn't need to survive rotation.
The mistake I see constantly: using StateFlow for navigation events. User logs in, you emit "NavigateToHome" to a StateFlow, and every time the screen recomposes, it tries to navigate again. Use SharedFlow for events. For how to wire these events to actual screen navigation with NavController, see the Jetpack Compose Navigation guide. If you're on the latest libraries, the Jetpack Navigation 3 migration guide covers type-safe routes with data classes instead of strings.
Building a Jetpack Compose Login Screen with StateFlow and SharedFlow
Let me walk through how I'd actually build a login feature. Not a toy example - one that handles network errors, loading states, and validation.
The State and Events
First, I define what state the UI needs and what events can happen:
data class LoginState(
val isLoading: Boolean = false,
val error: String? = null
)
sealed class LoginEvent {
object NavigateToHome : LoginEvent()
data class ShowError(val message: String) : LoginEvent()
}
Why separate State and Events? Because I learned this lesson the hard way. State is something the UI observes continuously - "are we loading?", "is there an error?". Events are fire-and-forget - "navigate to home screen" should happen once, not every time the composable recomposes.
The ViewModel
This is where most of the interesting stuff happens:
@HiltViewModel
class LoginViewModel @Inject constructor(
private val loginUseCase: LoginUseCase,
private val connectivityManager: ConnectivityManager
) : ViewModel() {
private val _state = MutableStateFlow(LoginState())
val state: StateFlow<LoginState> = _state.asStateFlow()
private val _events = MutableSharedFlow<LoginEvent>()
val events: SharedFlow<LoginEvent> = _events.asSharedFlow()
fun onLoginClick(email: String, password: String) {
// I always check network first - users hate seeing "unknown error"
// when they just forgot to turn off airplane mode
if (!isNetworkAvailable()) {
viewModelScope.launch {
_events.emit(LoginEvent.ShowError("No internet connection"))
}
return
}
if (email.isBlank() || password.isBlank()) {
viewModelScope.launch {
_events.emit(LoginEvent.ShowError("Please fill all fields"))
}
return
}
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val result = loginUseCase(email, password)
if (result.isSuccess) {
_state.value = _state.value.copy(isLoading = false)
_events.emit(LoginEvent.NavigateToHome)
} else {
_state.value = _state.value.copy(
isLoading = false,
error = result.message ?: "Login failed"
)
}
} catch (e: Exception) {
_state.value = _state.value.copy(
isLoading = false,
error = e.message ?: "An error occurred"
)
}
}
}
private fun isNetworkAvailable(): Boolean {
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}
A few things I want to point out:
The _state / state pattern - private mutable, public immutable. I've seen codebases where UI code directly modifies ViewModel state. Chaos ensues.
The network check happens before anything else. I put this in after getting too many bug reports that turned out to be "user was on a plane."
Notice I'm using viewModelScope.launch. This scope is tied to the ViewModel's lifecycle — when the ViewModel is cleared, any running coroutines get cancelled automatically. No memory leaks. If you're new to how coroutines work under the hood, the Kotlin coroutines guide covers the full mental model from callbacks to structured concurrency.
The UseCase
Some people skip this layer. I used to, until I had the same email validation logic copy-pasted in three different ViewModels. Now I always extract business logic:
class LoginUseCase @Inject constructor(
private val authRepository: AuthRepository
) {
suspend operator fun invoke(email: String, password: String): AuthResult {
if (!isValidEmail(email)) {
return AuthResult(
isSuccess = false,
message = "Invalid email format"
)
}
if (password.length < 6) {
return AuthResult(
isSuccess = false,
message = "Password must be at least 6 characters"
)
}
return authRepository.login(email, password)
}
private fun isValidEmail(email: String): Boolean {
return android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()
}
}
data class AuthResult(
val isSuccess: Boolean,
val message: String? = null,
val user: User? = null
)
The operator fun invoke trick lets you call the UseCase like a function: loginUseCase(email, password) instead of loginUseCase.execute(email, password). Small thing, but it reads nicer.
The Repository
Repository handles the actual data fetching. API calls, database queries, caching - all that lives here:
class AuthRepository @Inject constructor(
private val apiService: ApiService,
private val userPreferences: UserPreferences
) {
suspend fun login(email: String, password: String): AuthResult {
return try {
val response = apiService.login(LoginRequest(email, password))
if (response.isSuccessful && response.body() != null) {
val user = response.body()!!.user
// Save locally so user stays logged in
userPreferences.saveUser(user)
userPreferences.saveToken(response.body()!!.token)
AuthResult(isSuccess = true, user = user)
} else {
AuthResult(
isSuccess = false,
message = response.message() ?: "Login failed"
)
}
} catch (e: Exception) {
AuthResult(
isSuccess = false,
message = "Network error: ${e.localizedMessage}"
)
}
}
}
interface ApiService {
@POST("auth/login")
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
}
data class LoginRequest(val email: String, val password: String)
data class LoginResponse(val token: String, val user: User)
The UI
While structuring UI in MVVM, it's important to consider performance. Poor layout choices can lead to unnecessary recompositions, especially in complex screens — keep state reads as narrow as possible and avoid deeply nested layouts that re-evaluate on every state change.
Finally, the Compose UI. This should be the dumbest layer - it just displays state and forwards user actions to the ViewModel:
@Composable
fun LoginScreen(
viewModel: LoginViewModel = hiltViewModel(),
onNavigateToHome: () -> Unit
) {
val state by viewModel.state.collectAsState()
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
val context = LocalContext.current
// Handle one-time events
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is LoginEvent.NavigateToHome -> onNavigateToHome()
is LoginEvent.ShowError -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
}
}
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Login",
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.padding(bottom = 32.dp)
)
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
modifier = Modifier.fillMaxWidth(),
enabled = !state.isLoading,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email)
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
modifier = Modifier.fillMaxWidth(),
enabled = !state.isLoading,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
)
if (state.error != null) {
Text(
text = state.error!!,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp)
)
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = { viewModel.onLoginClick(email, password) },
modifier = Modifier.fillMaxWidth(),
enabled = !state.isLoading
) {
if (state.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Login")
}
}
}
}
That LaunchedEffect(Unit) block is how you handle SharedFlow events in Compose. It starts collecting when the composable enters composition and stops when it leaves.
The enabled = !state.isLoading on the text fields and button prevents users from spam-clicking while a request is in flight. Learned that one from production crash logs.
Hilt Dependency Injection Setup for Jetpack Compose MVVM
I use Hilt for dependency injection. It's more setup than Koin, but the compile-time safety is worth it - you find out about missing dependencies before your app crashes on a user's phone.
// Application class
@HiltAndroidApp
class MyApplication : Application()
// Activity
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyAppTheme {
LoginScreen()
}
}
}
}
// Modules for providing dependencies
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideRetrofit(): Retrofit {
return Retrofit.Builder()
.baseUrl("https://api.example.com/")
.addConverterFactory(GsonConverterFactory.create())
.build()
}
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService {
return retrofit.create(ApiService::class.java)
}
@Provides
@Singleton
fun provideConnectivityManager(
@ApplicationContext context: Context
): ConnectivityManager {
return context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
}
}
The Data Flow (How It All Connects)
Here's what happens when a user taps the login button:
User taps Login
↓
LoginScreen calls viewModel.onLoginClick()
↓
ViewModel checks network, validates input
↓
ViewModel calls loginUseCase(email, password)
↓
UseCase validates business rules (email format, password length)
↓
UseCase calls authRepository.login()
↓
Repository makes API call, saves token on success
↓
Result bubbles back up: Repository → UseCase → ViewModel
↓
ViewModel updates state (isLoading = false)
↓
ViewModel emits NavigateToHome event
↓
LoginScreen observes state change, UI updates
↓
LaunchedEffect catches event, triggers navigation
Common MVVM Mistakes in Jetpack Compose to Avoid
Passing Context to ViewModel - Don't do this. ViewModels outlive Activities. If your ViewModel holds a reference to a Context, you'll leak the entire Activity. If you need system services, inject them through Hilt like I did with ConnectivityManager.
Collecting Flows wrong in Compose - I once wrote viewModel.state.collect { } directly in a composable. The app recomposed infinitely. Use collectAsState() - it's designed for Compose.
Business logic in composables - It's tempting to add "just a small validation" in your onClick handler. Don't. That logic can't be unit tested, and it'll spread like a virus.
Using StateFlow for one-time events - Navigation, showing toasts, any action that should happen once - use SharedFlow. StateFlow replays the last value to new collectors, which means your navigation event fires again after screen rotation.
Forgetting to disable UI during loading - Users will spam that button if you let them. Always disable inputs while an operation is in progress.
Testing (Why This Architecture Pays Off)
The whole point of separating layers is testability. Here's how easy it becomes:
class LoginViewModelTest {
@Test
fun `login with valid credentials navigates to home`() = runTest {
val loginUseCase = mockk<LoginUseCase>()
coEvery { loginUseCase(any(), any()) } returns AuthResult(isSuccess = true)
val viewModel = LoginViewModel(loginUseCase, mockk(relaxed = true))
viewModel.onLoginClick("[email protected]", "password123")
// Verify navigation event was emitted
assert(viewModel.state.value.isLoading == false)
}
}
class LoginUseCaseTest {
@Test
fun `invalid email returns error`() = runTest {
val repository = mockk<AuthRepository>()
val useCase = LoginUseCase(repository)
val result = useCase("not-an-email", "password")
assert(!result.isSuccess)
assert(result.message == "Invalid email format")
// Repository was never called
coVerify(exactly = 0) { repository.login(any(), any()) }
}
}
No emulators, no UI testing frameworks, just fast unit tests. The UseCase test doesn't even need a mock repository for the invalid email case because the validation fails before reaching it.
Wrapping Up
MVVM isn't magic, and it won't fix bad code by itself. But it gives you a structure that makes the right thing easy and the wrong thing hard. State lives in ViewModels, business logic lives in UseCases, data fetching lives in Repositories, and your UI just displays what it's told.
The authentication example covers most patterns you'll need: state management, events, loading states, error handling, and dependency injection. Once you've built one feature this way, the rest follow the same pattern.
In Jetpack Compose, structuring your UI correctly is just as important as architecture. See how to use Row, Column, and Box effectively — the layout choices you make inside these ViewModel-backed screens directly impact recomposition performance. For complex UI cases where nested rows and columns get unwieldy, ConstraintLayout still has a place — here's when — here's when it's the right call.
Features like dynamic language switching are best handled at the ViewModel level too — exposing the current locale as StateFlow keeps Compose UI in sync across configuration changes without losing form state.
Once your app grows past a single screen, you'll hit the next set of problems: search that fires an API call on every keystroke, pagination state that breaks when the user searches mid-scroll, and feature modules that need to talk to each other without circular dependencies. Part 2 covers exactly those patterns — with the same production-first approach used here.
Continue to Part 2: Advanced MVVM Patterns — Search, Pagination & Cross-Module Communication