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,
/** Monotonic submit counter — drives shake. A repeated invalid submit yields an
* EQUAL FieldValidationState, so a state diff alone can never re-fire the shake. */
val submitAttempts: Int = 0,
) {
/** 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),
submitAttempts = model.submitAttempts + 1,
)
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()
.validationBehavior(
isError = model.emailState.isError,
shakeTrigger = model.submitAttempts,
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()
.validationBehavior(
isError = model.passwordState.isError,
shakeTrigger = model.submitAttempts,
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 is driven by a monotonic counter, not a state diff.
FieldValidationStateequality includes its message, so a second invalid submit produces an==state — no recomposition, and a diff-driven shake would fire once and never again. Thread asubmitAttemptscounter in the model and pass it asshakeTrigger;Modifier.validationBehavior(isError, shakeTrigger, onFocusLost)bundles that with focus-loss handling.Modifier.shakeOnInvalid(isError, trigger)is the standalone form. - Screen readers get no signal from shake. It is a purely visual
graphicsLayertranslation, and a repeat invalid submit changes nothing in the semantics tree. If the form must be accessible, announce the failure yourself on submit. - Give disposable fields a stable
key. The "don't shake on first frame" guard remembers the trigger it was born with, so a field re-created after its subtree was disposed — aLazyColumnitem without a stablekey, a nav destination re-entered — adopts the current counter as its new baseline and swallows any shake that became due while it was gone. onFocusCursorToEndneeds aTextFieldValuein your model, not aString. The examples on this page hoistString, so adopting cursor-to-end means migrating the field's model type.TextFieldValueis a value type and is safe inside an immutable model.
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.