MVI / UDF
Recommended wiring: value-hoist. A reducer's contract is (Model, Event) -> Model
with an immutable model — owning a mutable validator inside that model fights the
pattern (and FieldValidator's own docs forbid it). Instead, hoist the raw value and
fold the rules with the pure toFieldState(value, showError) helpers; the immutable
FieldValidationState rides in the model like any other value.
Model and events
// Plain top-level rule lists: shared by reduce() and any unit test, no Compose needed.
private val emailRules: List<ValueValidatorRule<String>> = listOf(Required, Email)
private val passwordRules: List<ValueValidatorRule<String>> = listOf(Required, MinLength(8))
// Every field is a val; FieldValidationState is @Immutable, so the Model is value-stable.
data class SignUpModel(
val email: String = "",
val emailState: FieldValidationState = FieldValidationState.Pristine,
val password: String = "",
val passwordState: FieldValidationState = FieldValidationState.Pristine,
/** null = not attempted, true = accepted, false = blocked (errors surfaced). */
val submitted: Boolean? = null,
) {
/** Driven purely by severity (ignores showError) — live from the first frame. */
val canSubmit: Boolean get() = listOf(emailState, passwordState).hasNoErrors()
}
/** Seed with rules pre-run (showError = false) so canSubmit is honest before any input. */
fun initialSignUpModel(): SignUpModel = SignUpModel(
emailState = emailRules.toFieldState("", showError = false),
passwordState = passwordRules.toFieldState("", showError = false),
)
sealed interface SignUpEvent {
data class EmailChanged(val value: String) : SignUpEvent
data object EmailFocusLost : SignUpEvent
data class PasswordChanged(val value: String) : SignUpEvent
data object PasswordFocusLost : SignUpEvent
data object Submit : SignUpEvent
}
The reducer
// THE pure core: (Model, Event) -> Model. No Compose, no coroutines, no IO.
// showError is threaded, not recomputed: Changed preserves it; FocusLost/Submit force it true.
fun reduce(model: SignUpModel, event: SignUpEvent): SignUpModel = when (event) {
is SignUpEvent.EmailChanged -> model.copy(
email = event.value,
emailState = emailRules.toFieldState(event.value, model.emailState.showError),
submitted = null,
)
SignUpEvent.EmailFocusLost -> model.copy(
emailState = emailRules.toFieldState(model.email, showError = true),
)
is SignUpEvent.PasswordChanged -> model.copy(
password = event.value,
passwordState = passwordRules.toFieldState(event.value, model.passwordState.showError),
submitted = null,
)
SignUpEvent.PasswordFocusLost -> model.copy(
passwordState = passwordRules.toFieldState(model.password, showError = true),
)
SignUpEvent.Submit -> {
// Surface errors on every field, then read validity off severity.
val surfaced = model.copy(
emailState = emailRules.toFieldState(model.email, showError = true),
passwordState = passwordRules.toFieldState(model.password, showError = true),
)
surfaced.copy(submitted = surfaced.canSubmit)
}
}
The store
// Single source of truth + single dispatch entry point. The only Compose-aware piece;
// swap for a ViewModel + StateFlow without touching reduce/Model/Event.
@Stable
class SignUpStore(initial: SignUpModel = initialSignUpModel()) {
var model by mutableStateOf(initial)
private set
fun dispatch(event: SignUpEvent) {
model = reduce(model, event)
}
}
The UI
@Composable
fun MviSignUpScreen(store: SignUpStore = remember { SignUpStore() }) {
val model = store.model
Column {
OutlinedTextField(
value = model.email,
onValueChange = { store.dispatch(SignUpEvent.EmailChanged(it)) },
label = { Text("Email") },
isError = model.emailState.isError,
supportingText = model.emailState.supportingText(),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.onFocusLost { store.dispatch(SignUpEvent.EmailFocusLost) },
)
OutlinedTextField(
value = model.password,
onValueChange = { store.dispatch(SignUpEvent.PasswordChanged(it)) },
label = { Text("Password") },
isError = model.passwordState.isError,
supportingText = model.passwordState.supportingText(),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.onFocusLost { store.dispatch(SignUpEvent.PasswordFocusLost) },
)
Button(
onClick = { store.dispatch(SignUpEvent.Submit) },
modifier = Modifier.fillMaxWidth(),
) { Text(if (model.canSubmit) "Create account" else "Fix errors to submit") }
model.submitted?.let { Text(if (it) "Valid — proceeding" else "Fix the errors above") }
}
}
Notes on the wiring:
showErroris threaded, not recomputed.Changedevents preserve the currentshowError(severity updates silently while typing);FocusLostandSubmitforce ittrue. This reproduces the "don't flash red mid-keystroke" behavior the in-composition validators give you for free.- The reducer is plain Kotlin — no Compose, no coroutines — so
reduce(model, Submit)is trivially unit-testable. - The store is swappable. Replace
SignUpStorewith a ViewModel exposing aStateFlow<SignUpModel>(or your MVI framework's container) without touchingreduce, the model, or the events. - Cross-field rules (confirm-password, etc.) are SAM lambdas closing over the model —
see custom rules. The built-in
PasswordMatchescouples to a mutable validator and does not fit a reducer. - Shake stays UI-owned: keep a
rememberShakingState()in the screen and callshakingState.shake()when an invalid submit reducessubmittedtofalse.
Persistence & process death
FieldValidationState is @Immutable @Serializable — severity + showError
persist, the message (result) is @Transient and recomputes. Persist the drafts plus
each field's showError with rememberSaveable (a FieldValidationState.Saver is
provided), then rehydrate on restore by re-running the rules through toFieldState
so the message reappears with showError preserved. For cross-process JSON, add a
kotlinx-serialization runtime and encode FieldValidationState directly (it persists
Outcome by constant name). See the runnable, process-death-surviving version in
MviSample.kt.