Why You Should Stop Passing ViewModels Around Your Compose UI Tree ðŸš«

 Master State Hoisting and the "Route-level" pattern to build scalable, testable, and preview-friendly Android apps.

Why You Should Stop Passing ViewModels Around Your Compose UI Tree

If you’ve been building with Jetpack Compose for a while, you know how tempting it is to just pass a ViewModel instance down through five layers of UI components. It’s convenient—one object gives you all the data and all the functions you need.

But here is the architectural reality: Passing ViewModel instances deep into your UI tree is an architectural anti-pattern. It couples your visual components to business logic and makes your code significantly harder to maintain, test, and preview.

The Core Problem: Lifecycle and Scoping

ViewModels are designed to be state holders tied to a specific lifecycle (usually a Navigation Destination). When you pass a ViewModel into a small UI component, you break the Unidirectional Data Flow (UDF).

❌ The “Bad” Way (Tight Coupling)

// ❌ BAD: This component is now impossible to preview or reuse
@Composable
fun ProfileHeader(viewModel: UserProfileViewModel) {
Text(text = viewModel.userName)
}

✅ The “Better” Way (Stateless)

// ✅ BETTER: Just pass the data you need
@Composable
fun ProfileHeader(userName: String) {
Text(text = userName)
}

The Solution: State Hoisting & Immutable UI State

The gold standard in Compose is State Hoisting. Instead of the child component reaching out for data, the parent “hoists” the state up and passes down only what is necessary: Immutable State and Lambda Callbacks.

The Expert Pattern: Route vs. Screen

To build scalable apps, we distinguish between the Route (the logic connector) and the Screen (the visual layout).

  • Route-level: Handles DI, ViewModel injection, and lifecycle observation.
  • Screen-level: A stateless Composable that only knows about data classes and lambdas.
// 1. Define an immutable snapshot of the UI
data class UserProfileUiState(
val userName: String = "",
val bio: String = "",
val isFollowing: Boolean = false
)

// 2. The ViewModel manages logic using StateFlow
class UserProfileViewModel : ViewModel() {
private val _uiState = MutableStateFlow(UserProfileUiState())

// StateFlow is the standard for ViewModels (Lifecycle-aware & Testable)
val uiState: StateFlow<UserProfileUiState> = _uiState.asStateFlow()

fun toggleFollow() { /* Logic updates _uiState */ }
}

// 3. The ROUTE observes the ViewModel
@Composable
fun UserProfileRoute(viewModel: UserProfileViewModel = hiltViewModel()) {
// collectAsStateWithLifecycle stops collection in the background
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

UserProfileScreen(
uiState = uiState,
onFollowClick = { viewModel.toggleFollow() }
)
}

🛠️ Recommended Project Template

To enforce this boundary, structure your feature folders like this:

ui/feature_profile/
├── ProfileViewModel.kt // StateFlow logic
├── ProfileUiState.kt // Immutable data snapshot
├── ProfileRoute.kt // Logic/DI Boundary
├── ProfileScreen.kt // Stateless UI Layout
└── components/ // Small, reusable, "dumb" widgets
├── ProfileHeader.kt
└── FollowButton.kt

💡 Architectural Nuances

  • StateFlow vs. mutableStateOf: Use StateFlow in ViewModels for better testing and platform independence. Save mutableStateOf for local UI state (like a toggle switch that doesn't need business logic).
  • Prop Drilling: If you have too many arguments, don’t reach for the ViewModel! Group your data into Nested UiStates or group callbacks into a single action interface.
  • Immutability: Never mutate UiState properties. Always use .copy() to emit a new snapshot. This is the "secret sauce" for Compose performance.

Why This Wins: Effortless Previews & Testing

Because your UI is now Stateless, you can visualize every state — Loading, Error, or Success — without ever running an emulator.

Multiple Previews for Different States

@Preview(name = "Following State", showBackground = true)
@Composable
fun PreviewFollowing() {
UserProfileScreen(
uiState = UserProfileUiState(userName = "John", isFollowing = true),
onFollowClick = {}
)
}

High-Speed UI Testing

Testing becomes a breeze because you don’t need to mock a ViewModel or handle Coroutine Dispatchers.

@Test
fun profileScreen_showsUsername() {
composeTestRule.setContent {
UserProfileScreen(
uiState = UserProfileUiState(userName = "Jane Doe"),
onFollowClick = {}
)
}
composeTestRule.onNodeWithText("Jane Doe").assertExists()
}

🙋‍♂️ Frequently Asked Questions (FAQs)

Is it ever okay to pass a ViewModel down?

In software architecture, “never” is rare. For a tiny internal tool or temporary scaffolding, it might be fine. But for production apps, sticking to this principle prevents technical debt.

Should I use CompositionLocal to avoid prop drilling?

Use CompositionLocal for truly global data (Themes, User Sessions). Do not use it as a hidden tunnel to pass ViewModels or screen-specific state; that makes your code "magical" and hard to debug.

🎯 Your Weekend Challenge

Pick one screen in your app. Extract the ViewModel logic into a Route and make the rest of the screen Stateless. You’ll notice your Previews start working again instantly!

Happy coding! ðŸš€

📘 Master Your Next Technical Interview

Since Java is the foundation of Android development, mastering DSA is essential. I highly recommend “Mastering Data Structures & Algorithms in Java”. It’s a focused roadmap covering 100+ coding challenges to help you ace your technical rounds.


Comments

Popular posts from this blog

Stop Writing Massive when Statements: Master the State Pattern in Kotlin

Coroutines & Flows: 5 Critical Anti-Patterns That Are Secretly Slowing Down Your Android App

Master Time with Kotlin's Stable Timing API: Beyond System.nanoTime()