Forms and validation in Compose: lessons from production
Getting form input right is one of those tasks that seems straightforward until you ship to production and discover users entering nonsense, hitting empty states, or abandoning checkout because the keyboard won't dismiss. Forms and validation in Compose: lessons from production distils patterns we've refined across e-commerce, travel, and SaaS platforms—moving beyond basic TextFields to production-ready user input handling that respects both developer and user needs.
TextField state — Hoist values to the ViewModel
The tempting shortcut is to manage TextField state inside a Composable using a local remember. That works for throwaway prototypes, but the moment you need to validate, clear fields after submit, or restore state after a configuration change, you'll regret it. Production-grade forms hoist state up to the ViewModel where it belongs.
Here's the pattern we use across our team:
data class FormState(
val email: String = "",
val fullName: String = "",
val isSubmitting: Boolean = false,
val errors: Map<String, String> = emptyMap()
)
class RegistrationViewModel : ViewModel() {
private val _formState = MutableStateFlow(FormState())
val formState = _formState.asStateFlow()
fun updateEmail(newEmail: String) {
_formState.update { it.copy(email = newEmail) }
}
fun updateFullName(newName: String) {
_formState.update { it.copy(fullName = newName) }
}
}Notice we store errors as a Map keyed by field name. This scales better than individual error fields and keeps the ViewModel lean. The Composable then reads this state and reacts to changes:
@Composable
fun RegistrationForm(viewModel: RegistrationViewModel) {
val formState by viewModel.formState.collectAsState()
Column(modifier = Modifier.padding(16.dp)) {
TextField(
value = formState.email,
onValueChange = { viewModel.updateEmail(it) },
label = { Text("Email") },
isError = "email" in formState.errors,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
)
)
if ("email" in formState.errors) {
Text(
formState.errors["email"] ?: "",
color = Color.Red,
fontSize = 12.sp
)
}
}
}By hoisting state, we gain testability (mock the ViewModel and verify state changes), reusability (the form Composable doesn't care about business logic), and correctness (state survives lifecycle events because it lives in the ViewModel).
Validate on change or submit — Show errors under fields
Deciding when to validate is a product decision, but production forms typically use both strategies. Validate on submit to catch multiple errors at once; validate on change to provide real-time feedback after the user has finished a field.
Here's how we structure validation in the ViewModel:
class RegistrationViewModel : ViewModel() {
private val _formState = MutableStateFlow(FormState())
val formState = _formState.asStateFlow()
private fun validateEmail(email: String): String? {
return when {
email.isBlank() -> "Email is required"
!email.contains("@") -> "Invalid email format"
else -> null
}
}
private fun validateFullName(name: String): String? {
return when {
name.isBlank() -> "Full name is required"
name.length < 2 -> "Name must be at least 2 characters"
else -> null
}
}
fun validateField(fieldName: String) {
val error = when (fieldName) {
"email" -> validateEmail(_formState.value.email)
"fullName" -> validateFullName(_formState.value.fullName)
else -> null
}
_formState.update { state ->
state.copy(
errors = if (error != null) {
state.errors + (fieldName to error)
} else {
state.errors - fieldName
}
)
}
}
fun submitForm() = viewModelScope.launch {
val emailError = validateEmail(_formState.value.email)
val nameError = validateFullName(_formState.value.fullName)
val newErrors = mutableMapOf<String, String>()
if (emailError != null) newErrors["email"] = emailError
if (nameError != null) newErrors["fullName"] = nameError
_formState.update { it.copy(errors = newErrors) }
if (newErrors.isEmpty()) {
_formState.update { it.copy(isSubmitting = true) }
try {
// Call API
} finally {
_formState.update { it.copy(isSubmitting = false) }
}
}
}
}In the Composable, call validateField in an onValueChange lambda after a slight delay to avoid hammering validation on every keystroke. Use LaunchedEffect with a debounce if you're checking against a server:
TextField(
value = formState.email,
onValueChange = { newValue ->
viewModel.updateEmail(newValue)
viewModel.validateField("email")
},
label = { Text("Email") },
isError = "email" in formState.errors
)Show the error message immediately beneath the field. This pattern—field, error, visual feedback—is familiar to users and accessible to screen readers.
Keyboard options — Email, number and password keyboards
Showing the right keyboard dramatically improves input accuracy and speed. Mobile users expect an email keyboard when they see an email field, a numeric keypad for amounts, and a password keyboard (usually hiding characters) for secrets.
Use KeyboardOptions to specify type and action:
TextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
)
)
TextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { viewModel.submitForm() }
)
)
TextField(
value = amount,
onValueChange = { amount = it },
label = { Text("Amount") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Decimal,
imeAction = ImeAction.Next
)
)Pairing keyboard type with ImeAction and KeyboardActions creates a smooth flow. When users tap the email field, they see the email keyboard with @ readily available. When they finish and tap Next, focus moves to the password field—no manual tapping required. This attention to detail reduces friction and abandonment.
For password fields, consider using PasswordVisualTransformation to hide characters, but also provide a toggle to show/hide for accessibility. Some users need to verify what they've typed, especially on small screens.
Accessibility — Labels and error semantics for screen readers
Accessible forms aren't an afterthought; they're part of correctness. Screen reader users need clear field labels, error announcements, and logical focus flow. Here's how to get it right:
@Composable
fun AccessibleTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
error: String? = null,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
TextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
isError = error != null,
modifier = Modifier
.fillMaxWidth()
.semantics {
contentDescription = if (error != null) {
"$label, Error: $error"
} else {
label
}
}
)
if (error != null) {
Text(
error,
color = Color.Red,
fontSize = 12.sp,
modifier = Modifier
.padding(top = 4.dp)
.semantics {
liveRegion = LiveRegionMode.Polite
}
)
}
}
}The semantics modifier tells TalkBack and other screen readers what to announce. When an error appears, set liveRegion = LiveRegionMode.Polite so the reader announces it without interrupting the user's current action. The contentDescription includes the field label so screen reader users know what they're typing into, not just the cursor position.
In the ViewModel, structure your form so tab order makes sense. Group related fields and announce completion or errors clearly. Test with TalkBack on an actual device—it catches gaps that reading docs never will.
Common mistakes
- Storing form state in Composables: Using
rememberfor everything means state doesn't survive configuration changes and becomes hard to test. Hoist to the ViewModel consistently. - Validating every keystroke without debouncing: Firing validation on every character change floods the UI and drains the battery. Use a debounce or validate only on blur and submit.
- Ignoring keyboard type: Leaving
keyboardType = KeyboardType.Textfor an email field forces users to manually switch keyboards or makes them enter invalid data. Match keyboard type to the field. - No error messaging: A red border means nothing to users. Always show a short, actionable error message under the field.
- Skipping accessibility testing: Running through the app with a screen reader once takes 30 minutes and catches issues that affect users permanently. Do it.
Key takeaways
- Hoist form state to the ViewModel using
StateFlowso it survives lifecycle events and stays testable. - Validate on both change (with debounce) and submit to provide real-time feedback without overwhelming the user.
- Match
KeyboardOptionsto field type (email, password, decimal) and chainKeyboardActionsto move focus smoothly. - Add
semanticsto TextFields and errors so screen readers announce content description and live region updates. - Test forms with a real screen reader and check keyboard flow on an actual device—emulators miss details.






Comments
Loading comments…