Why I split every Compose screen into two Composables
A small Compose pattern that earns back its boilerplate ten times over — once you start using @Preview seriously and writing Hilt-free unit tests for your screens.
When you wire a Hilt-injected ViewModel into a Compose screen, you have two options. Most tutorials show you the simple one. After thirteen screens of Tarotscope, I’m convinced the other one is worth the boilerplate.
Option A: hiltViewModel() inside Screen
@Composable
fun HomeScreen(
onNavigateToPull: () -> Unit,
viewModel: HomeViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsState()
// render state, call viewModel methods on user interaction
}
This is the path of least resistance. One Composable per screen. The default value on viewModel makes the call site clean. The Hilt entry point lives where you’d expect.
It works. It’s also the wrong default for any project where you care about previews and tests.
What it costs you
@Preview requires a fake Hilt environment to render HomeScreen. Without one, the preview throws — there’s no graph to construct HomeViewModel from. You can mock the VM, but then you’re back to passing it explicitly, which defeats the simpler-looking signature.
Unit tests that render HomeScreen need either a Hilt graph or a per-test fake VM. Either way you’re carrying DI ceremony into the layer that should be pure rendering.
Layout Inspector and the design tooling go through your runtime DI, which means design-time iteration is coupled to runtime correctness. A misconfigured @HiltViewModel annotation breaks your previews. A repository that returns a Flow with the wrong key blocks the preview from rendering.
The seam between “stateful machinery” and “pure UI” is sitting inside the Screen Composable, but nothing in the type signature tells you where it is.
Option B: Stateful Route + stateless Screen
// HomeScreen.kt — both Composables in one file
@Composable
fun HomeRoute(
onNavigateToPull: () -> Unit,
onNavigateToCollection: () -> Unit,
viewModel: HomeViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsState()
HomeScreen(
state = state,
onNavigateToPull = onNavigateToPull,
onNavigateToCollection = onNavigateToCollection,
onPullCard = viewModel::pullCard,
onTodaysPullTap = viewModel::onTodaysPullTap,
)
}
@Composable
fun HomeScreen(
state: HomeUiState,
onNavigateToPull: () -> Unit,
onNavigateToCollection: () -> Unit,
onPullCard: () -> Unit,
onTodaysPullTap: () -> Unit,
) {
// pure rendering — no Hilt, no Flow collection, no IO
// every input is a primitive, data class, or callback
}
@Preview(showBackground = true)
@Composable
private fun HomeScreenPreview() {
TarotscopeTheme {
HomeScreen(
state = sampleHomeUiState,
onNavigateToPull = {},
onNavigateToCollection = {},
onPullCard = {},
onTodaysPullTap = {},
)
}
}
HomeScreen knows nothing about Hilt, Flow, ViewModels, or repositories. Its inputs are state and callbacks. It can be rendered in isolation with mock data and zero ceremony.
HomeRoute is the thin stateful wrapper that resolves the ViewModel, collects the state, and forwards events. It lives in the same file as HomeScreen — colocation by convention, not by enforcement.
NavGraph registers the Route, never the Screen:
composable<Home> {
HomeRoute(
onNavigateToPull = { navController.navigate(Pull) },
onNavigateToCollection = { navController.navigate(Collection) },
)
}
The Screen takes only state and callbacks, which means it has nothing the navigation layer needs. The compiler walls off the routing concern from the rendering concern.
What you get back
@Preview just works. Mock the UiState, get a live preview in 200ms. I iterate visual choices on Tarotscope screens without ever spinning up the emulator. The feedback loop is 10× faster than it would have been with Option A.
Unit tests don’t need a Hilt graph. composeTestRule.setContent { HomeScreen(state = ...) } and assert. Callback verification uses a recording lambda. The test stays in the rendering layer.
A designer who knows Compose can iterate the Screen file alone. They never touch Hilt, never construct a ViewModel, never read a repository. The interface they work against is a data class and a handful of callbacks — exactly the shape of a designed-then-handed-off contract.
Bug isolation gets sharper. Visual issue → look at Screen. State-not-updating issue → look at Route’s collectAsState (or the VM). Loading-state issue → look at the VM. The split tells you where to debug.
The exceptions I made
Two screens in Tarotscope don’t follow the pattern.
OnboardingFlow is a 5-step orchestrator with internal state — language picker, welcome carousel, birthdate input, name input, tutorial pull intro. The Route layer would just unwrap one VM and pass everything down. Instead, the orchestrator IS the Route: it owns the step state machine and calls its sub-screens (LanguageSelectScreen, BirthdateInputScreen, …) which stay stateless. NavGraph registers the orchestrator, not the sub-screens. They aren’t reachable from outside the flow.
SplashScreen is dead-simple. It exposes one StateFlow<SplashTarget> that resolves to either Onboarding or Home. The Route wrapper would be three lines of pure forwarding. So SplashScreen takes viewModel: SplashViewModel = hiltViewModel() as a default parameter. Acceptable for a screen this small and ephemeral. The convention is the default, not the law.
Eleven of thirteen wired screens follow the pattern. Two earn an exception by being structurally different.
When the pattern doesn’t pay off
A static legal page (Privacy Policy, Terms of Service) has no state and no callbacks. There’s nothing to hoist. Render it directly.
A demo app you’re throwing away in a week, where you don’t write tests and don’t use @Preview. The boilerplate has no return on that timeline.
Apps that don’t use DI at all. If your VM is constructed manually with simple parameters, the Route adds a layer that doesn’t separate anything new.
For everything else — production app, multiple developers, design iteration that matters, tests that need to run fast — the pattern earns its boilerplate the first time you preview a screen on your laptop without launching the emulator.
The cost, stated honestly
Roughly thirty lines of boilerplate per screen. One file convention to remember (Route + Screen colocation). One mental rule (NavGraph imports Route, not Screen). A new contributor needs five minutes of orientation to understand which Composable goes where and why.
That’s it.
The lesson
Picking the seam between “stateful machinery” and “pure UI” is one of the highest-leverage decisions in any UI codebase. Compose makes the seam possible by giving you composability — but it doesn’t make the seam obvious. Most tutorials show you the inline-hiltViewModel() pattern because it’s compact, and you adopt it without thinking, and three months later you’re spinning up an emulator to validate a button color.
The Route + Screen split names the seam. After thirteen screens of using it, I don’t think about whether to split anymore — only about which layer a given concern belongs in. That clarity is the whole win.
If you’re starting a new Compose project, try this for one screen and run @Preview on it before you commit. Then try Option A on the next screen and compare. You’ll know which one to keep.
Drafted 2026-05-05. Pattern documented as ADR-009 in the Tarotscope repo (private during dev).