ViewModel
Recommended wiring: the validator lives in the ViewModel. FieldValidator has a
plain constructor and is snapshot-state backed, which means it works outside
composition: own it in the VM and the form state survives configuration changes,
submit() is unit-testable with no Compose UI, and any composable reading
field.value/field.state still recomposes automatically.
If your team's convention is strictly-immutable UI state, the value-hoist wiring keeps the ViewModel pure at the cost of more plumbing per field.
class SignUpViewModel : ViewModel() {
// FieldValidator has a plain (non-@Composable) constructor — safe to own in a
// ViewModel. It is snapshot-state backed, so composables reading value/state
// recompose automatically, and it survives configuration changes with the VM.
val email = FieldValidator("", rules = listOf(Required, Email))
val password = FieldValidator("", rules = listOf(Required, MinLength(8)))
/** Form-level submit: shows errors on every field, true when all pass. Unit-testable without UI. */
fun submit(): Boolean = listOf(email, password).validate()
}
@Composable
fun SignUpScreen(
onSubmit: () -> Unit,
viewModel: SignUpViewModel = viewModel { SignUpViewModel() },
) {
Column {
ValidatedField(viewModel.email, label = "Email")
ValidatedField(viewModel.password, label = "Password")
Button(
// submit() surfaces errors on every field and returns true only when all pass.
onClick = { if (viewModel.submit()) onSubmit() },
modifier = Modifier.fillMaxWidth(),
) { Text("Create account") }
}
}
// The @Composable rendering helpers are called HERE, in composition — never in the
// ViewModel. isError is a plain property; supportingText() resolves string resources.
@Composable
private fun ValidatedField(field: FieldValidator<String>, label: String) {
OutlinedTextField(
value = field.value,
onValueChange = field::onValueChange,
label = { Text(label) },
isError = field.state.isError,
supportingText = field.state.supportingText(),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.onFocusLost { field.onFocusLost() },
)
}
private val emailRules = listOf(Required, Email)
private val passwordRules = listOf(Required, MinLength(8))
data class SignUpUiState(
val email: String = "",
val emailState: FieldValidationState = FieldValidationState.Pristine,
val password: String = "",
val passwordState: FieldValidationState = FieldValidationState.Pristine,
) {
val canSubmit: Boolean get() = listOf(emailState, passwordState).hasNoErrors()
}
class HoistedSignUpViewModel : ViewModel() {
private val _state = MutableStateFlow(
// Seed by folding the rules with showError = false so canSubmit is honest
// before any input (a pristine required field must not look submittable).
SignUpUiState(
emailState = emailRules.toFieldState("", showError = false),
passwordState = passwordRules.toFieldState("", showError = false),
)
)
val state: StateFlow<SignUpUiState> = _state.asStateFlow()
fun onEmailChange(value: String) = _state.update {
// Recompute severity on every keystroke, but PRESERVE showError so no
// error pops while the user is still typing.
it.copy(email = value, emailState = emailRules.toFieldState(value, it.emailState.showError))
}
fun onEmailFocusLost() = _state.update {
it.copy(emailState = emailRules.toFieldState(it.email, showError = true))
}
fun onPasswordChange(value: String) = _state.update {
it.copy(password = value, passwordState = passwordRules.toFieldState(value, it.passwordState.showError))
}
fun onPasswordFocusLost() = _state.update {
it.copy(passwordState = passwordRules.toFieldState(it.password, showError = true))
}
/** Submit: force errors visible on all fields, then read validity off severity. */
fun submit(): Boolean = _state.updateAndGet {
it.copy(
emailState = emailRules.toFieldState(it.email, showError = true),
passwordState = passwordRules.toFieldState(it.password, showError = true),
)
}.canSubmit
}
@Composable
fun HoistedSignUpScreen(
viewModel: HoistedSignUpViewModel = viewModel { HoistedSignUpViewModel() },
) {
val state by viewModel.state.collectAsState()
Column {
OutlinedTextField(
value = state.email,
onValueChange = viewModel::onEmailChange,
label = { Text("Email") },
isError = state.emailState.isError,
supportingText = state.emailState.supportingText(),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.onFocusLost(viewModel::onEmailFocusLost),
)
OutlinedTextField(
value = state.password,
onValueChange = viewModel::onPasswordChange,
label = { Text("Password") },
isError = state.passwordState.isError,
supportingText = state.passwordState.supportingText(),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.onFocusLost(viewModel::onPasswordFocusLost),
)
Button(
onClick = { viewModel.submit() },
modifier = Modifier.fillMaxWidth(),
) { Text(if (state.canSubmit) "Create account" else "Fix errors to submit") }
}
}
The @Composable boundary
The ViewModel never calls the @Composable rendering helpers. field.state.isError
is a plain property the VM can read; supportingText() resolves string resources
and must be called from the UI, in composition. Keep severity logic in the state
holder and rendering in the composable.
Construct once, never copy
Construct a FieldValidator once and hold it (a VM/presenter field, DI, or
remember). Don't construct it inside a recomposing body and don't copy it — both
reset its validation state.
Reformatting fields keep the cursor
The ValidatedField binder above uses the plain String overload of
OutlinedTextField for brevity. For fields that reformat as the user types
(currency, phone), bind a local TextFieldValue and push only .text to
field.onValueChange, so the cursor/selection survives the reformat — otherwise the
caret jumps to the end on every keystroke. See ValidatedTextField in the
PresenterSample.
Observability
FieldValidator takes an optional observer — a FieldValidatorObserver fired after
every mutation commits (ValueChanged / Validated / Reset) with the post-mutation
value + state. Handy for analytics, logging, or driving UI like the sample's live
state-flow visualizer
(StateFlowSample.kt).
No event fires at construction; observers must not throw.
On restore-from-persistence, prefer FieldValidator(initial = restoredDraft, rules,
initialValidate = true) over calling validate() — it re-runs the rules without
emitting a Validated event an analytics tap would over-count.
Unit-testing presenters
If presenter/VM unit tests construct FieldValidator or read .value/.state,
add org.jetbrains.compose:runtime to the test source set — it is not pulled in
transitively.