← Back to list

Stop Passing ViewModels to Your Composables?

Rethinking Screen Design in Jetpack Compose Navigation 3

chanzmao in ProAndroidDev · 2026-05-08 19:17 · 52 claps · 4.0 min read
#jetpack-compose #android-app-development #mobile-app-development #programming #kotlin
Open on Medium ↗
Wiki topics: 💻 · Programming 📱 · Mobile Development

Stop Passing ViewModels to Your Composables?

Rethinking Screen Design in Jetpack Compose Navigation 3

Image generated by Gemini

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.

[embed]Stop Building God ViewModels with "One Screen = One ViewModel" Stop Building God ViewModels with "One Screen = One ViewModel" Using the New Android ViewModel APIs for Lifecycle-Aware…proandroiddev.com

[embed]The Type-Safe Pattern Without SavedStateHandle: Hilt Assisted Injection with Jetpack Navigation 3 The Type-Safe Pattern Without SavedStateHandle: Hilt Assisted Injection with Jetpack Navigation 3 Build route-scoped…proandroiddev.com

[embed]ViewModel はいつ生まれていつ死ぬか 【→ Jetpack Compose】 *ViewModel は onCleared() を自分が破棄されるときに実行します。 public abstract class ViewModel { /* This method will be called when this…*android.benigumo.com

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