Wnb viewmodel udf
Skill wenubey/claude-android-skills/skills/wnb-viewmodel-udf
Reusable Claude Code skills for Android — Kotlin, Jetpack Compose, unit + UI testing, animations
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.
2 things to look at
- 15 days oldThe repository was created 15 days ago. New is not bad, but a brand new repository carrying a familiar-sounding name is the shape a typosquat arrives in, and there has been no time for anyone else to find a problem with it.
- 0 stars0 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.
What its author says it does
Copied from the file, not written here
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".
SKILL.md
6.8 KB, 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.
Gives 0 of the 12 instructions most refactoring skills give
Counted across 521 of the 525 authors here whose files we hold, read 2026-08-06
- run tests after each changein 59 of 521, across 56 files
- write tests before refactoringin 27 of 521, across 24 files
- preserve external behaviorin 26 of 521, across 22 files
- remove dead codein 25 of 521, across 24 files
- make small incremental changesin 20 of 521, across 17 files
- break the implementation into tiny commitsin 18 of 521, across 5 files
- ask the user about alternative optionsin 17 of 521, across 4 files
- create a GitHub issue with the planin 17 of 521, across 4 files
- explore the repository to verify assertionsin 17 of 521, across 4 files
- interview the user about the refactorin 16 of 521, across 3 files
- check the codebase for test coveragein 16 of 521, across 3 files
- refactor one thing at a timein 16 of 521, across 12 files
Said here and by no other author read
- Define one immutable state class per ViewModel
- Expose one StateFlow for UI state
- Route all UI events through one onAction method
- Define actions in a sealed interface
- Inject DispatcherProvider for IO operations
- Handle one-shot events via MutableSharedFlow
Grouped from the skills themselves: near-identical wordings counted once, and counted by distinct author, so one author publishing three of these counts once.