Skip to content

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:

  • showError is threaded, not recomputed. Changed events preserve the current showError (severity updates silently while typing); FocusLost and Submit force it true. 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 SignUpStore with a ViewModel exposing a StateFlow<SignUpModel> (or your MVI framework's container) without touching reduce, the model, or the events.
  • Cross-field rules (confirm-password, etc.) are SAM lambdas closing over the model — see custom rules. The built-in PasswordMatches couples to a mutable validator and does not fit a reducer.
  • Shake stays UI-owned: keep a rememberShakingState() in the screen and call shakingState.shake() when an invalid submit reduces submitted to false.

Persistence & process death

FieldValidationState is @Immutable @Serializableseverity + 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.