The forms module provides a declarative, type-safe validation system for managing form state entirely within your UiState. No external state holders, no side channels — just clean data classes with built-in validation.
| Class | Purpose |
|---|---|
FormField<T> |
Holds a field's value, errors, and metadata (touched, dirty) |
Validator<T> |
Type alias (T) -> String? — returns error message or null |
Validators |
Collection of built-in validators (required, email, minLength, etc.) |
commonMain.dependencies {
implementation("com.helloanwar.mvvmate:forms:<version>")
}A FormField wraps a single form value with validation metadata:
data class FormField<T>(
val value: T, // Current value
val errors: List<String> = emptyList(), // Validation error messages
val isTouched: Boolean = false, // User has interacted
val isDirty: Boolean = false // Value has been changed
)| Property | Type | Description |
|---|---|---|
isValid |
Boolean |
true if errors is empty |
isInvalid |
Boolean |
true if errors is not empty |
Updates the value, runs all validators, and marks the field as dirty and touched:
val field = FormField("")
val updated = field.setValue("hello@test.com", Validators.required(), Validators.email())
// updated.value = "hello@test.com"
// updated.isDirty = true
// updated.isTouched = true
// updated.errors = [] (valid email)Marks the field as touched without changing its value, and runs validators. Use this on form submit to surface errors on fields the user hasn't interacted with yet:
val field = FormField("") // untouched, no errors yet
val touched = field.markTouched(Validators.required())
// touched.isTouched = true
// touched.errors = ["Required"]All validators return null (valid) or an error message string (invalid):
| Validator | Description | Default Message |
|---|---|---|
Validators.required(message) |
Not null or blank | "Required" |
Validators.email(message) |
Standard email format | "Invalid email" |
Validators.minLength(len, message) |
At least len characters |
"Must be at least {len} characters" |
Validators.maxLength(len, message) |
At most len characters |
"Must be at most {len} characters" |
Validators.pattern(regex, message) |
Matches regex | "Invalid format" |
Validators.digitsRequired(message) |
Only digits (0-9) | "Must contain only digits" |
Validators.decimalRequired(message) |
Valid decimal number | "Must be a valid decimal number" |
Note: Validators for
String?types skip validation if the value isnullor blank (exceptrequired). This means chainingrequired()+email()won't double-report on an empty field.
Since Validator<T> is just a (T) -> String? typealias, you can create custom validators trivially:
fun passwordStrength(): Validator<String?> = { value ->
when {
value == null || value.length < 8 -> "Must be at least 8 characters"
!value.any { it.isUpperCase() } -> "Must contain an uppercase letter"
!value.any { it.isDigit() } -> "Must contain a digit"
else -> null
}
}
// Usage:
updateState {
copy(password = password.setValue(action.value, Validators.required(), passwordStrength()))
}data class RegistrationState(
val email: FormField<String> = FormField(""),
val password: FormField<String> = FormField(""),
val age: FormField<String> = FormField(""),
val isSubmitting: Boolean = false,
val successMessage: String? = null
) : UiState {
val isFormValid: Boolean
get() = email.isValid && password.isValid && age.isValid &&
email.isDirty && password.isDirty && age.isDirty
}
sealed interface RegistrationAction : UiAction {
data class EmailChanged(val email: String) : RegistrationAction
data class PasswordChanged(val password: String) : RegistrationAction
data class AgeChanged(val age: String) : RegistrationAction
data object Submit : RegistrationAction
}class RegistrationViewModel : BaseViewModel<RegistrationState, RegistrationAction>(
RegistrationState()
) {
override suspend fun onAction(action: RegistrationAction) {
when (action) {
is RegistrationAction.EmailChanged -> updateState {
copy(email = email.setValue(action.email, Validators.required(), Validators.email()))
}
is RegistrationAction.PasswordChanged -> updateState {
copy(password = password.setValue(
action.password,
Validators.required(),
Validators.minLength(8, "Password must be at least 8 characters")
))
}
is RegistrationAction.AgeChanged -> updateState {
copy(age = age.setValue(
action.age,
Validators.required(),
Validators.digitsRequired("Age must be a number")
))
}
RegistrationAction.Submit -> {
// Mark all fields touched to surface errors
val touchedState = state.value.copy(
email = state.value.email.markTouched(Validators.required(), Validators.email()),
password = state.value.password.markTouched(
Validators.required(), Validators.minLength(8)
),
age = state.value.age.markTouched(
Validators.required(), Validators.digitsRequired()
)
)
if (touchedState.isFormValid) {
updateState { touchedState.copy(isSubmitting = true) }
// Submit to API...
updateState { copy(isSubmitting = false, successMessage = "Registered!") }
} else {
updateState { touchedState }
}
}
}
}
}@Composable
fun RegistrationScreen(viewModel: RegistrationViewModel = viewModel()) {
val state by viewModel.state.collectAsState()
Column(modifier = Modifier.padding(16.dp)) {
// Email field
OutlinedTextField(
value = state.email.value,
onValueChange = { viewModel.handleAction(RegistrationAction.EmailChanged(it)) },
label = { Text("Email") },
isError = state.email.isTouched && state.email.isInvalid
)
if (state.email.isTouched && state.email.isInvalid) {
Text(
text = state.email.errors.first(),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
// Password field
OutlinedTextField(
value = state.password.value,
onValueChange = { viewModel.handleAction(RegistrationAction.PasswordChanged(it)) },
label = { Text("Password") },
isError = state.password.isTouched && state.password.isInvalid
)
if (state.password.isTouched && state.password.isInvalid) {
Text(
text = state.password.errors.first(),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
// Submit button
Button(
onClick = { viewModel.handleAction(RegistrationAction.Submit) },
enabled = !state.isSubmitting
) {
Text(if (state.isSubmitting) "Submitting..." else "Register")
}
}
}By default, setValue marks the field as isTouched = true, so errors show immediately. If you prefer to show errors only after the user clicks "Submit":
- On input change: Call
setValuebut use a custom extension that doesn't setisTouched, or track a separateisSubmitAttemptedboolean in your state. - On submit: Call
markTouched(...)on every field to surface all errors at once. - In Compose: Only render error text when
field.isTouched && field.isInvalid.
For fields that depend on each other (e.g., "confirm password"), use derived properties:
data class SignupState(
val password: FormField<String> = FormField(""),
val confirmPassword: FormField<String> = FormField("")
) : UiState {
val passwordsMatch: Boolean
get() = password.value == confirmPassword.value
val isFormValid: Boolean
get() = password.isValid && confirmPassword.isValid && passwordsMatch
}