Wnb viewmodel udf
Skill wenubey/claude-android-skills/skills/wnb-viewmodel-udf
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".From its SKILL.md
npx -y skills add wenubey/claude-android-skills --skill wnb-viewmodel-udfAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
6.8 KB, ~1.5k tokens by cl100k_base, as published. Nobody here has run it
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, immutabledata class, mutated viacopy(). Everything the UI renders is a field on this class — loading, error, list contents, dialog visibility, form input. - One
StateFlow<XxxState>. BackingMutableStateFlowis private (_state); public isval state: StateFlow<XxxState> = _state.asStateFlow(). - One
onAction(action: XxxAction)entry point. All UI events flow through it.XxxActionis a sealed interface (or sealed class) declared in the same file or feature package. No publiconXxx()per-event methods. - No
mutableStateOfinside a ViewModel. Compose-side only. - No string keys for navigation. Use type-safe route classes.
- IO always goes through an injected
DispatcherProvider. NeverDispatchers.IOdirectly — it can't be swapped in tests. - Repositories return
Result<T>orFlow<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 asSharedFlow<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
XxxStateand observing it from Compose → causes replay on rotation or when the screen re-collects. UseSharedFlowfor one-shot events. - Injecting
Dispatchers.IOdirectly → not swappable in tests, so tests hit real threads and become flaky. Always injectDispatcherProvider. - Multiple public
onXxx()methods instead of a singleonAction→ 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
Throwableand swallowing it → alwaysTimber.e(throwable, "ClassName: message")before mutating state. init { }doing IO on the main thread → wrap inviewModelScope.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. Usedata classfields (isDialogVisible: Boolean) andsealed interfacevariants.
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.
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.