Android ViewModel — UDF contract
This skill encodes a single ViewModel shape used across a Kotlin + Jetpack Compose codebase. Do not invent variants. If a rule blocks the task, stop and surface the conflict — don't silently break the contract.
Non-negotiables
- One state class per ViewModel. Named
XxxState, immutable data class, mutated via copy(). Everything the UI renders is a field on this class — loading, error, list contents, dialog visibility, form input.
- One
StateFlow<XxxState>. Backing MutableStateFlow is private (_state); public is val state: StateFlow<XxxState> = _state.asStateFlow().
- One
onAction(action: XxxAction) entry point. All UI events flow through it. XxxAction is a sealed interface (or sealed class) declared in the same file or feature package. No public onXxx() per-event methods.
- No
mutableStateOf inside a ViewModel. Compose-side only.
- No string keys for navigation. Use type-safe route classes.
- IO always goes through an injected
DispatcherProvider. Never Dispatchers.IO directly — it can't be swapped in tests.
- Repositories return
Result<T> or Flow<T>.
Result<T> → .onSuccess { _state.update { ... } }.onFailure { Timber.e(it, "..."); _state.update { it.copy(error = ...) } }
Flow<T> → .catch { Timber.e(it, "..."); _state.update { ... } }.collect { ... }
- One-shot events (navigation, snackbar, toast) go through
MutableSharedFlow<T>(extraBufferCapacity = 1), exposed as SharedFlow<T>. Never on the state — putting them there causes replay on config change / process death.
- Error logging:
Timber.e(throwable, "ClassName: what failed") — throwable is the first argument.
- Mutation is always atomic:
_state.update { it.copy(...) }. Never _state.value = _state.value.copy(...) (race window between read and write).
Canonical example
class FooViewModel(
private val fooRepository: FooRepository,
private val dispatcherProvider: DispatcherProvider,
) : ViewModel() {
private val ioDispatcher = dispatcherProvider.io()
private val _state = MutableStateFlow(FooState())
val state: StateFlow<FooState> = _state.asStateFlow()
private val _navigationEvent = MutableSharedFlow<String>(extraBufferCapacity = 1)
val navigationEvent: SharedFlow<String> = _navigationEvent.asSharedFlow()
init {
observeFoos()
}
private fun observeFoos() {
viewModelScope.launch(ioDispatcher) {
fooRepository.observeFoos()
.catch { error ->
Timber.e(error, "FooViewModel: observeFoos failed")
_state.update { it.copy(isLoading = false, error = error.message) }
}
.collect { foos ->
_state.update { it.copy(foos = foos, isLoading = false) }
}
}
}
fun onAction(action: FooAction) {
when (action) {
is FooAction.Refresh -> refresh()
is FooAction.Select -> _state.update { it.copy(selected = action.foo) }
is FooAction.DismissError -> _state.update { it.copy(error = null) }
is FooAction.NavigateToDetail -> emitNavigation(action.fooId)
}
}
private fun refresh() {
viewModelScope.launch(ioDispatcher) {
_state.update { it.copy(isLoading = true, error = null) }
fooRepository.refresh()
.onSuccess {
_state.update { it.copy(isLoading = false) }
}
.onFailure { error ->
Timber.e(error, "FooViewModel: refresh failed")
_state.update { it.copy(isLoading = false, error = error.message) }
}
}
}
private fun emitNavigation(fooId: String) {
viewModelScope.launch { _navigationEvent.emit(fooId) }
}
}
data class FooState(
val foos: List<Foo> = emptyList(),
val selected: Foo? = null,
val isLoading: Boolean = true,
val error: String? = null,
)
sealed interface FooAction {
data object Refresh : FooAction
data class Select(val foo: Foo) : FooAction
data object DismissError : FooAction
data class NavigateToDetail(val fooId: String) : FooAction
}
Common mistakes
_state.value = _state.value.copy(...) → use _state.update { it.copy(...) }. Why: atomic, no lost-write race under concurrent emissions.
- Putting a navigation route on
XxxState and observing it from Compose → causes replay on rotation or when the screen re-collects. Use SharedFlow for one-shot events.
- Injecting
Dispatchers.IO directly → not swappable in tests, so tests hit real threads and become flaky. Always inject DispatcherProvider.
- Multiple public
onXxx() methods instead of a single onAction → each screen ends up different; contract drifts. Keep the single funnel.
- Business logic inside a Composable → move to the ViewModel, or extract to a UseCase in the domain layer for anything with rules (pricing, validation, eligibility).
- Catching
Throwable and swallowing it → always Timber.e(throwable, "ClassName: message") before mutating state.
init { } doing IO on the main thread → wrap in viewModelScope.launch(ioDispatcher).
- State with imperative fields like
showDialog: () -> Unit → state describes what the UI is, not what it does. Actions describe what the UI does. Use data class fields (isDialogVisible: Boolean) and sealed interface variants.
When to extract to a UseCase
If the ViewModel is orchestrating rules (discount math, coupon eligibility, cart pricing, form validation) — extract to a use case in the domain module (:domain/usecase/) with its own pure JUnit test. The ViewModel then calls the use case and only maps its result to state. If the ViewModel is just observing → mapping → rendering, no use case needed.
Related skills
[[wnb-viewmodel-test]] — the matching unit-test skeleton for this ViewModel shape.
[[wnb-koin-feature-module]] — how to wire this ViewModel into DI with a feature-scoped Koin module.
1---2name: wnb-viewmodel-udf3description: Use this skill when writing, reviewing, or refactoring an Android ViewModel that follows unidirectional data flow (UDF). Enforces a single StateFlow<XxxState>, a sealed XxxAction, one onAction() entry point, injected DispatcherProvider for IO, MutableSharedFlow only for one-shot navigation/toast events, Result.onSuccess/onFailure from repositories, Timber for logging. Applies to Kotlin + Jetpack Compose projects using MVVM + UDF. Triggers on "new viewmodel", "add viewmodel", "refactor viewmodel", "UDF", "UiState", "XxxState", "onAction", "sealed action", "StateFlow", "MutableStateFlow", "unidirectional data flow", "MVI-lite", "one-shot event", "SharedFlow event".4---56# Android ViewModel — UDF contract78This skill encodes a single ViewModel shape used across a Kotlin + Jetpack Compose codebase. Do not invent variants. If a rule blocks the task, stop and surface the conflict — don't silently break the contract.910## Non-negotiables11121. **One state class per ViewModel.** Named `XxxState`, immutable `data class`, mutated via `copy()`. Everything the UI renders is a field on this class — loading, error, list contents, dialog visibility, form input.132. **One `StateFlow<XxxState>`.** Backing `MutableStateFlow` is private (`_state`); public is `val state: StateFlow<XxxState> = _state.asStateFlow()`.143. **One `onAction(action: XxxAction)` entry point.** All UI events flow through it. `XxxAction` is a sealed interface (or sealed class) declared in the same file or feature package. No public `onXxx()` per-event methods.154. **No `mutableStateOf` inside a ViewModel.** Compose-side only.165. **No string keys for navigation.** Use type-safe route classes.176. **IO always goes through an injected `DispatcherProvider`.** Never `Dispatchers.IO` directly — it can't be swapped in tests.187. **Repositories return `Result<T>` or `Flow<T>`.**19 - `Result<T>` → `.onSuccess { _state.update { ... } }.onFailure { Timber.e(it, "..."); _state.update { it.copy(error = ...) } }`20 - `Flow<T>` → `.catch { Timber.e(it, "..."); _state.update { ... } }.collect { ... }`218. **One-shot events** (navigation, snackbar, toast) go through `MutableSharedFlow<T>(extraBufferCapacity = 1)`, exposed as `SharedFlow<T>`. Never on the state — putting them there causes replay on config change / process death.229. **Error logging:** `Timber.e(throwable, "ClassName: what failed")` — throwable is the first argument.2310. **Mutation is always atomic:** `_state.update { it.copy(...) }`. Never `_state.value = _state.value.copy(...)` (race window between read and write).2425## Canonical example2627```kotlin28class FooViewModel(29 private val fooRepository: FooRepository,30 private val dispatcherProvider: DispatcherProvider,31) : ViewModel() {3233 private val ioDispatcher = dispatcherProvider.io()3435 private val _state = MutableStateFlow(FooState())36 val state: StateFlow<FooState> = _state.asStateFlow()3738 private val _navigationEvent = MutableSharedFlow<String>(extraBufferCapacity = 1)39 val navigationEvent: SharedFlow<String> = _navigationEvent.asSharedFlow()4041 init {42 observeFoos()43 }4445 private fun observeFoos() {46 viewModelScope.launch(ioDispatcher) {47 fooRepository.observeFoos()48 .catch { error ->49 Timber.e(error, "FooViewModel: observeFoos failed")50 _state.update { it.copy(isLoading = false, error = error.message) }51 }52 .collect { foos ->53 _state.update { it.copy(foos = foos, isLoading = false) }54 }55 }56 }5758 fun onAction(action: FooAction) {59 when (action) {60 is FooAction.Refresh -> refresh()61 is FooAction.Select -> _state.update { it.copy(selected = action.foo) }62 is FooAction.DismissError -> _state.update { it.copy(error = null) }63 is FooAction.NavigateToDetail -> emitNavigation(action.fooId)64 }65 }6667 private fun refresh() {68 viewModelScope.launch(ioDispatcher) {69 _state.update { it.copy(isLoading = true, error = null) }70 fooRepository.refresh()71 .onSuccess {72 _state.update { it.copy(isLoading = false) }73 }74 .onFailure { error ->75 Timber.e(error, "FooViewModel: refresh failed")76 _state.update { it.copy(isLoading = false, error = error.message) }77 }78 }79 }8081 private fun emitNavigation(fooId: String) {82 viewModelScope.launch { _navigationEvent.emit(fooId) }83 }84}8586data class FooState(87 val foos: List<Foo> = emptyList(),88 val selected: Foo? = null,89 val isLoading: Boolean = true,90 val error: String? = null,91)9293sealed interface FooAction {94 data object Refresh : FooAction95 data class Select(val foo: Foo) : FooAction96 data object DismissError : FooAction97 data class NavigateToDetail(val fooId: String) : FooAction98}99```100101## Common mistakes102103- **`_state.value = _state.value.copy(...)`** → use `_state.update { it.copy(...) }`. Why: atomic, no lost-write race under concurrent emissions.104- **Putting a navigation route on `XxxState` and observing it from Compose** → causes replay on rotation or when the screen re-collects. Use `SharedFlow` for one-shot events.105- **Injecting `Dispatchers.IO` directly** → not swappable in tests, so tests hit real threads and become flaky. Always inject `DispatcherProvider`.106- **Multiple public `onXxx()` methods instead of a single `onAction`** → each screen ends up different; contract drifts. Keep the single funnel.107- **Business logic inside a Composable** → move to the ViewModel, or extract to a UseCase in the domain layer for anything with rules (pricing, validation, eligibility).108- **Catching `Throwable` and swallowing it** → always `Timber.e(throwable, "ClassName: message")` before mutating state.109- **`init { }` doing IO on the main thread** → wrap in `viewModelScope.launch(ioDispatcher)`.110- **State with imperative fields like `showDialog: () -> Unit`** → state describes what the UI *is*, not what it *does*. Actions describe what the UI does. Use `data class` fields (`isDialogVisible: Boolean`) and `sealed interface` variants.111112## When to extract to a UseCase113114If the ViewModel is orchestrating **rules** (discount math, coupon eligibility, cart pricing, form validation) — extract to a use case in the domain module (`:domain/usecase/`) with its own pure JUnit test. The ViewModel then calls the use case and only maps its result to state. If the ViewModel is just observing → mapping → rendering, no use case needed.115116## Related skills117118- `[[wnb-viewmodel-test]]` — the matching unit-test skeleton for this ViewModel shape.119- `[[wnb-koin-feature-module]]` — how to wire this ViewModel into DI with a feature-scoped Koin module.