Skip to main content

Command Palette

Search for a command to run...

Preview-Safe ViewModels in Jetpack Compose with Hilt

Learn how to build preview-safe ViewModels in Jetpack Compose using Hilt.

Updated
•4 min read•View as Markdown
Preview-Safe ViewModels in Jetpack Compose with Hilt
R

Senior Android Engineer from Bangladesh. Love to contribute in Open-Source. Indie Music Producer.

Jetpack Compose simplifies UI development, but when combined with Hilt and Compose Preview, it exposes a subtle architectural challenge that many teams encounter in real projects.

This article explains:

  • the real problem

  • why it happens

  • why some “clean” solutions fail

  • the production-safe solution

  • whether this approach is industry-standard

The problem

In a typical Compose screen, it’s tempting to write:

@Composable
fun Screen(
    vm: MyViewModel = hiltViewModel()
)

This works perfectly at runtime, but crashes in Compose Preview with errors like:

java.lang.NoSuchMethodException: MyViewModel.<init>()

Why this happens

Compose Preview:

  • does not run inside a real Activity

  • does not initialize Hilt

  • does not create a dependency graph

When hiltViewModel() is invoked in Preview, Compose attempts to instantiate the ViewModel using a no-arg constructor, which Hilt ViewModels intentionally do not have.

This is expected behavior.

Why common fixes are wrong

Some common (but incorrect) workarounds:

  • adding a no-arg constructor ❌

  • disabling Preview ❌

  • duplicating composables just for Preview ❌

These approaches:

  • break dependency-injection guarantees

  • introduce technical debt

  • do not scale in large codebases

The correct mental model

Compose Preview is not runtime.

Therefore:

  • Hilt must never be invoked in Preview

  • UI must not depend on concrete ViewModel implementations

  • ViewModel creation must be explicit, not inferred

The solution is abstraction + explicit wiring.

Step 1: define a ViewModel contract (interface)

Instead of exposing a concrete ViewModel, define what the UI actually needs.

interface Step1ViewModel {
    suspend fun submit(
        locale: String,
        selectedState: OnboardingState
    ): Result<Unit>
}

Why this matters

  • UI depends on behavior, not implementation

  • enables fake implementations

  • enables testing

  • enables Preview

Step 2: implement the real Hilt ViewModel

@HiltViewModel
class Step1ViewModelImpl @Inject constructor(
    private val repository: Repository
) : ViewModel(), Step1ViewModel {

    override suspend fun submit(
        locale: String,
        selectedState: OnboardingState
    ): Result<Unit> = withContext(Dispatchers.IO) {

        val response = repository.submit(locale, selectedState)

        if (response.isSuccessful) {
            Result.success(Unit)
        } else {
            Result.failure(response.error)
        }
    }
}

Why return Result

  • clear success / failure semantics

  • no callback nesting

  • structured concurrency

  • test-friendly API

Step 3: create a fake ViewModel for Preview

class FakeStep1ViewModel : ViewModel(), Step1ViewModel {

    override suspend fun submit(
        locale: String,
        selectedState: OnboardingState
    ): Result<Unit> {
        return Result.success(Unit)
    }
}

The fake ViewModel:

  • has no dependencies

  • returns deterministic results

  • allows UI rendering and interaction in Preview

The tempting abstraction — and why it failed

A common next step is to introduce a helper that tries to “automatically” choose between a fake ViewModel and a Hilt ViewModel based on Preview detection.

While this looks clean, it introduces a serious problem:

  • Preview detection relies on tooling signals

  • tooling signals are not runtime guarantees

  • fake ViewModels can leak into real app execution

  • behavior becomes non-deterministic and hard to debug

This approach hides a critical architectural decision behind a helper function.

The key realization

Preview is a build-time concern, not a runtime concern.

Runtime code should never guess whether it is running in Preview.

Once this is accepted, the correct solution becomes obvious.

The production-safe architecture

The rule

A composable must not decide how its ViewModel is constructed.
That responsibility belongs to the caller.


Pure UI composable (no DI knowledge)

@Composable
fun Step1Screen(
    vm: Step1ViewModel
) {
    // UI logic
}

Runtime wiring (NavHost / Activity)

composable("step1") {
    Step1Screen(
        vm = hiltViewModel<Step1ViewModelImpl>()
    )
}

Preview wiring (explicit fake)

@Preview(showBackground = true)
@Composable
fun Step1ScreenPreview() {
    AppTheme {
        Step1Screen(
            vm = FakeStep1ViewModel()
        )
    }
}
  • No guessing.

  • No inspection flags.

  • No runtime ambiguity.

Is this a standard production approach?

Yes.

This pattern aligns with:

  • Clean Architecture

  • Google’s Compose samples

  • test-driven UI development

  • large-scale Android apps

Why this scales in production

✔ Separation of concerns
UI depends on interfaces, not DI frameworks

✔ Testability
Fake ViewModels work in unit and UI tests

✔ Stability
No reflection hacks, no no-arg constructor abuse

✔ Maintainability
Clear boundaries and explicit ownership

Conclusion

  • Compose Preview is not runtime

  • Hilt ViewModels must not be invoked implicitly

  • UI should depend on interfaces

  • Fake ViewModels belong to Preview and tests

  • ViewModel creation must be explicit and controlled

If a composable needs a ViewModel, it should never decide how that ViewModel is constructed — only what it can do.


That’s it for today. Happy coding…