Stop Passing ViewModels to Your Composables?
Rethinking Screen Design in Jetpack Compose Navigation 3
Stop Passing ViewModels to Your Composables?
Rethinking Screen Design in Jetpack Compose Navigation 3

Image generated by Gemini
If you’re building with Jetpack Compose Navigation 3, one design question appears surprisingly early:
What should a screen actually receive?
Should your composable take:
HomeScreen(viewModel)
or:
HomeScreen(uiState)
or maybe:
HomeScreen(person)
At first, all three seem fine.
Then you check the official Navigation 3 samples and notice something interesting:
They often pass the ViewModel directly.
So… case closed?
Not quite.
The answer is more nuanced.
Let’s break down the trade-offs and what Navigation 3 actually changes.
🌱 The Example
Consider this Navigation 3 setup:
Scaffold { paddingValues ->
val backStack = rememberNavBackStack(Home)
NavDisplay(
backStack = backStack,
modifier = Modifier.padding(paddingValues),
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
entry<Home> { key ->
val viewModel = viewModel(factory = HomeViewModel.Factory(key))
// A - ViewModel
HomeScreen(
viewModel = viewModel,
onNext = { backStack.add(PersonDetailsForm(viewModel.person)) }
)
// B - UiState
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
HomeScreen(
uiState = uiState,
onNext = { backStack.add(PersonDetailsForm(uiState.person)) },
onAction = { viewModel.doSomething() }
)
// C - Plain values
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
HomeScreen(
person = uiState.person,
onNext = { backStack.add(PersonDetailsForm(uiState.person)) },
onAction = { viewModel.doSomething() }
)
}
entry<PersonDetailsForm> { key ->
// ...
This gives us three common architectural approaches.
🌱 Pattern A: Pass the ViewModel
HomeScreen(viewModel)
This is the most straightforward.
Inside the screen:
@Composable
fun HomeScreen(
viewModel: HomeViewModel
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
}
Simple.
Fast.
Minimal ceremony.
Why developers like it
It reduces boilerplate.
You don’t need to manually wire state and callbacks.
It feels natural.
The trade-offs
Tight coupling
- Your UI now depends on a specific ViewModel.
- Reuse becomes difficult.
Harder previews
- Compose previews now need ViewModel setup.
- That adds friction.
Harder UI testing
Even simple rendering tests often require mocking ViewModel behavior.
When it works well
This is perfectly reasonable for:
- small screens
- internal tools
- rapid prototypes
- low-complexity apps
And yes — this is also what many official samples do.
We’ll come back to that.
🌱 Pattern B: Pass UiState
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
HomeScreen(
uiState = uiState,
onAction = viewModel::doSomething
)
This is often the sweet spot.
Why it works
Easy previews
@Preview
@Composable
fun PreviewHome() {
HomeScreen(
uiState = HomeUiState(...)
)
}
No ViewModel required.
Better separation of concerns
- The ViewModel owns state.
- The composable renders it.
- Clear boundary.
Easier testing
You can inject state directly.
The downside
UiState can become bloated.
This:
data class HomeUiState(
val person: Person,
val loading: Boolean,
val error: String?,
val snackbar: String?,
val selectedTab: Int,
val scrollOffset: Int
)
can quickly turn into a dumping ground.
🌱 Pattern C: Pass Plain Values
Instead of exposing state objects:
HomeScreen(
person = uiState.person,
isLoading = uiState.loading
)
This is the most explicit approach.
Benefits
- The API tells you exactly what the screen needs.
- It’s highly reusable.
- It’s extremely preview-friendly.
Cost
Parameter lists can grow fast.
This:
HomeScreen(
person = ...,
loading = ...,
error = ...,
selectedTab = ...,
onRetry = ...,
onSave = ...
)
gets noisy.
Fast.
🌱 So Which One Is Best?
Usually:
Small/simple screens:
Pass ViewModel
Most production screens:
Pass UiState
Highly reusable screens:
Pass plain values
Visualized:
Maintainability
↑
│
Plain │
│
UiState │
│
ViewModel │
────────────────────────→ Implementation speed
There isn’t one universal answer.
It depends on the screen.
🌱 But Wait — The Official Navigation 3 Samples Pass ViewModels
This is where things get interesting.
If you check the official Navigation 3 recipes, many examples pass ViewModels directly into composables.
At first glance, that seems to contradict the argument for UiState boundaries.
It doesn’t.
Why the official samples do this
Because they optimize for teaching API mechanics.
Their goal is to show:
- how entry() works
- ViewModel scoping
- argument passing
- Navigation 3 integration
They are intentionally minimal.
They are not prescribing full production architecture.
That distinction matters.
Official sample code answers:
“How does Navigation 3 work?”
It does not necessarily answer:
“How should I structure my production screens?”
Those are different questions.
🌱 What Most Compose Developers Think
From Android community discussions, developers generally fall into three camps.
Camp A: Pass the ViewModel
Their argument:
Compose is already framework-aware. Extra abstraction is unnecessary.
Good fit for:
- smaller apps
- fast-moving teams
- pragmatic architectures
Camp B: Pass UiState
Their argument:
It balances simplicity with testability.
This is probably the most common production preference.
Camp C: Pure Stateless Screens
Their argument:
Composables should be pure rendering functions.
Common when:
- building reusable design systems
- emphasizing previews
- sharing UI across features
🌱 What Navigation 3 Changes
Navigation 3 introduces something subtle but important:
entry<Home> { key ->
val viewModel = viewModel(...)
}
The entry becomes a natural architectural boundary.
That makes it an ideal place to:
- create ViewModels
- collect state
- wire events
- handle navigation
This enables a cleaner split.
🌱 My Recommendation
The official samples are not wrong.
They are simply optimized for teaching Navigation 3 itself.
For production apps, I’d treat direct ViewModel passing as:
A valid starting point, not necessarily the final architecture
If a screen is tiny, passing the ViewModel is fine.
As complexity grows, moving toward UiState or plain-value APIs usually pays off.
🌱 The Real Question
Instead of asking:
“Does the official sample pass a ViewModel?”
Ask:
“How much coupling is appropriate for this screen?”
That’s the architectural decision that actually matters.
Navigation 3 gives us flexibility.
The best choice depends on:
- screen complexity
- expected growth
- team preferences
- testing needs
And that flexibility is exactly what makes Navigation 3 interesting.
Thanks for reading! 👏 Clap to support the article ⭐ Follow me for more Android and Jetpack Compose tips 🔔 Turn on email notifications so you never miss a post
메타데이터
- post_id
- 46342a04ae87
- slug
- stop-passing-viewmodels-to-your-composables-46342a04ae87
- url
- https://proandroiddev.com/stop-passing-viewmodels-to-your-composables-46342a04ae87
- canonical_url
- https://proandroiddev.com/stop-passing-viewmodels-to-your-composables-46342a04ae87
- author_url
- https://medium.com/@chanzmao
- status
- ok
- fetched_at
- 2026-06-17 16:37:43