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
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…