Services Case Studies Insights About Start a project →

Compose previews that don't crash.

Mobile Published June 20, 2026 7 min read

A practical guide to reliable Jetpack Compose @Preview support, covering ViewModels, state hoisting, and dependency injection, reproducible from scratch.

Why previews break in the first place

Jetpack Compose previews are one of the best productivity features to land in Android development in years, when they actually work. The moment a composable pulls in a ViewModel that expects a Hilt or Koin graph, or reaches for a Context that assumes a running Activity, the preview render fails with a stack trace that tells you almost nothing useful. Most teams respond by giving up on previewing anything past the leaf components, which throws away most of the value the feature was supposed to provide in the first place.

The underlying cause is almost always the same one. A composable is written to fetch its own data by calling hiltViewModel() or viewModel() directly, instead of receiving already-resolved state as a plain parameter. The preview renderer executes your composable in an isolated environment with no dependency graph, no Activity, and no real Application context, so anything that reaches outside the function to pull its own dependencies is going to fail before a single pixel is drawn.

The rule: previews should never touch real dependencies

The fix is a discipline more than a library. A composable that is meant to be previewable should take its entire visual state as parameters, plain data classes and lambdas, and should never resolve a ViewModel, a repository, or a use case from inside its own body. The ViewModel resolution happens one level up, in the screen-level composable that is wired into navigation, and that screen-level composable is the one thing you accept you cannot preview directly. Everything below it can be previewed freely, because everything below it only knows about data, not about where that data came from.

This is the same state-hoisting principle Compose already encourages for testability and reuse. Preview-friendliness turns out to be almost a free side effect of following it properly, which is a good sign that it is the right pattern rather than an awkward workaround.

A concrete split

  • Route composable Owns navigation arguments, resolves the ViewModel via hiltViewModel(), collects state with collectAsStateWithLifecycle(), and passes plain state plus event lambdas down. Not previewable, and that is fine.
  • Screen composable Takes a state data class and a set of lambdas as parameters. Fully previewable, since nothing inside it reaches outward for anything.
  • Content composables Small, focused, and previewable in isolation, which is where most of your day-to-day preview iteration actually happens.

Fake ViewModels and preview-only factories

Sometimes a screen genuinely needs to preview with a ViewModel attached, usually because the ViewModel exposes more than simple state, such as a `SavedStateHandle`-backed value or derived flows you want to exercise in the preview itself. In that case, do not fight Hilt inside a preview. Construct the ViewModel manually with fake dependencies instead.

Define a small, in-memory fake for each repository or use case the ViewModel depends on, living in your `debug` or `androidTest` source set so it never ships to production. The fake returns canned data synchronously or via a `MutableStateFlow` you control, and the preview constructs the ViewModel directly with `remember { MyViewModel(fakeRepository) }` rather than asking Hilt to do it. This keeps the preview fast, deterministic, and completely detached from your real dependency graph, which is exactly what you want when you are iterating on layout and do not want an actual network fake to slow down every recomposition.

Covering every state with PreviewParameterProvider

A single preview showing the happy path tells you almost nothing about how a screen behaves under load, on error, or when empty. `PreviewParameterProvider` lets you drive a single preview function across several states without writing a separate function for each one.

  • Define a sealed state Loading, content, empty, and error, all sharing the same screen composable signature.
  • Implement the provider A class implementing PreviewParameterProvider<ScreenState> whose values sequence yields one instance per state.
  • Annotate the parameter @PreviewParameter(ScreenStateProvider::class) state: ScreenState on the composable's preview wrapper, and Android Studio renders every state side by side in the preview pane.

This one change catches a disproportionate share of real bugs before a device ever runs the app, because empty and error states are exactly the ones engineers forget to visually check, and exactly the ones that end up looking broken in production because nobody looked at them until a user hit one by accident.

Multipreview for device, theme, and font scale

Compose's multipreview annotations let you define a single custom annotation that bundles several `@Preview` configurations, then apply that one annotation to any composable to render it across all of them at once. A typical bundle covers a phone and a tablet width, light and dark theme, and a larger system font scale, since font-scale bugs in particular are the ones that almost never get caught any other way. Once the annotation exists, adding full device and accessibility coverage to a new screen costs one line rather than eight separate preview functions, which is the difference between this actually happening and it quietly not happening after the second sprint.

Common pitfalls that still slip through

  • LocalContext-dependent resources Loading a drawable, string, or dimension resource directly inside a composable body ties it to a real Context. Prefer stringResource() and friends, which work correctly inside previews, over manual LocalContext.current.resources calls.
  • Navigation controllers passed down too far A raw NavController threaded three levels deep into a content composable makes that composable unpreviewable and hard to test. Pass a lambda like onItemClick: (String) -> Unit instead, and let the route composable own the actual navigation call.
  • Coroutine scope assumptions Composables that launch a coroutine on rememberCoroutineScope() and immediately call a suspend function on a real repository will crash previews the moment that repository expects a live network or database connection. Keep suspend calls behind the same fake boundary as everything else.
  • Unstable preview data Building preview sample data inline with mutable lists or non-deterministic values, like System.currentTimeMillis(), causes previews to shift slightly on every recomposition and makes visual diffs in code review noisy. Use fixed, hardcoded sample data for anything that appears in a preview.

A checklist for reproducible previews

  • State in, events out Every previewable composable takes a plain state parameter and exposes behaviour through lambdas, never by resolving its own dependencies.
  • One route composable per screen Everything else underneath it is a pure function of its parameters and is previewable by construction.
  • Fakes live in debug source sets Never let a preview-only fake leak into a release build, and never let it silently call a real network client.
  • Every screen gets a state provider Loading, empty, error, and content, reviewed together, not just the happy path.
  • A shared multipreview annotation Applied consistently, so device size, theme, and font scale coverage is a byproduct of writing the screen, not a separate task someone has to remember.

None of this is exotic engineering. It is mostly the discipline of keeping composables honest about what they depend on, which happens to be the exact same discipline that makes them fast to preview, easy to test, and much less likely to surprise you the first time a real device runs them. We cover this kind of Android platform work, previews included, as part of our mobile engineering practice.

Keep reading

Wrestling with a Compose preview crash?

Start a conversation →
KT Solutions Assistant

Before we start, please share a few details so we can follow up with you.

Please enter your name and a valid email address.

End this conversation? Your chat will be emailed to us.