# Preview-Safe ViewModels in Jetpack Compose with Hilt

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:

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

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

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

![](https://files.ylnk.cc/assets/banner_primary.webp?t=1766487798972 align="center")

## 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.

```kotlin
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

```kotlin
@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

```kotlin
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
    

![](https://files.ylnk.cc/assets/banner_primary.webp?t=1766487798972 align="center")

## 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)

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

---

### Runtime wiring (NavHost / Activity)

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

### Preview wiring (explicit fake)

```kotlin
@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

![](https://files.ylnk.cc/assets/banner_primary.webp?t=1766487798972 align="center")

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