Form Validation in Jetpack Compose

Quick answer: Keep input values, validation messages, and submission state in your screen state holder. Let each
TextFieldrender its value and error, forward changes as events, and show a clear supporting message instead of relying only on a red outline. Validate on a timing that helps the user: while typing for simple constraints, on blur for distracting checks, and on submit for cross-field or server rules.
Form validation is product logic presented through a UI. Compose fields should not decide whether an email is valid, call a server, or keep a hidden copy of the form. They display the state chosen by the ViewModel and emit events when users type or submit.
Model values and errors explicitly
Use immutable UI state so a preview, test, and real screen see the same representation of the form.
data class RegistrationUiState(
val email: String = "",
val password: String = "",
val confirmPassword: String = "",
val emailError: String? = null,
val passwordError: String? = null,
val confirmPasswordError: String? = null,
val isSubmitting: Boolean = false,
)
sealed interface RegistrationAction {
data class EmailChanged(val value: String) : RegistrationAction
data class PasswordChanged(val value: String) : RegistrationAction
data class ConfirmationChanged(val value: String) : RegistrationAction
data object Submit : RegistrationAction
}The ViewModel receives each action, updates the value immediately, and computes the relevant error state. This preserves the state-hoisting pattern while keeping validation rules testable outside the composition.
Render errors with isError and supporting text
isError gives the Material field an error appearance, but a visible explanation tells users how to recover.
@Composable
fun EmailInput(
email: String,
errorMessage: String?,
onEmailChange: (String) -> Unit,
) {
OutlinedTextField(
value = email,
onValueChange = onEmailChange,
modifier = Modifier.fillMaxWidth(),
label = { Text("Email address") },
placeholder = { Text("name@example.com") },
singleLine = true,
isError = errorMessage != null,
supportingText = {
Text(errorMessage ?: "We use this for account messages.")
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next,
),
)
}Android’s validation quick guide demonstrates using isError and a message while users type. The Compose semantics guide also recommends expanded error semantics for custom error components. A message near the relevant field is essential because color alone is not accessible feedback.
Choose error timing intentionally
| Rule | Helpful timing | Example |
|---|---|---|
| Simple local rule | While typing or after first edit | Required, maximum length, allowed characters |
| Format rule | After enough input or focus loss | Email, phone, postal code |
| Cross-field rule | When either related field changes or on submit | Password confirmation, date range |
| Remote rule | After a deliberate debounce or submit | Username availability, promo code |
Do not show “invalid email” while the user has typed only one character. Track whether the field has been edited or whether a submit was attempted, then expose the error only at the moment that helps rather than interrupts.
fun validateRegistration(state: RegistrationUiState): RegistrationUiState {
val emailError = when {
state.email.isBlank() -> "Email is required."
!android.util.Patterns.EMAIL_ADDRESS.matcher(state.email).matches() ->
"Enter a valid email address."
else -> null
}
val confirmationError = when {
state.confirmPassword.isBlank() -> "Confirm your password."
state.confirmPassword != state.password -> "Passwords do not match."
else -> null
}
return state.copy(
emailError = emailError,
confirmPasswordError = confirmationError,
)
}This is an illustrative validator. Put it in a ViewModel, use case, or other testable layer—not inside onValueChange in a reusable composable. Keep remote validation separate from local format validation so a slow network never blocks ordinary typing.
Submit only when the form is ready
The submit button should reflect validation and submission state, while the ViewModel remains the final authority.
val canSubmit = !state.isSubmitting &&
state.emailError == null &&
state.passwordError == null &&
state.confirmPasswordError == null &&
state.email.isNotBlank() &&
state.password.isNotBlank() &&
state.confirmPassword.isNotBlank()
Button(
onClick = { onAction(RegistrationAction.Submit) },
enabled = canSubmit,
) {
Text(if (state.isSubmitting) "Creating account…" else "Create account")
}Still validate again when Submit reaches the state holder. A disabled button improves feedback, but it is not a security boundary and cannot replace authoritative validation on your backend. See Material 3 button states for enabled and loading patterns.
Focus, keyboard, and accessibility
Use ImeAction.Next for intermediate fields and ImeAction.Done for the final field. Move focus in visual order, keep a visible persistent label, and do not use a placeholder as the only instruction. For an error summary above a long form, add a short message and move focus to the first invalid field only after a submit attempt—not on every keystroke.
Material fields include useful semantics, but custom error banners should expose descriptive error(...) semantics when they convey information beyond the field. Test TalkBack, large fonts, keyboard navigation, and right-to-left layouts. The TextField guide covers field-level keyboard and styling choices.
Test rules and UI separately
Unit-test the validation function with valid, blank, malformed, and cross-field cases. Then write a Compose test that enters input, triggers submit, and asserts the visible message.
composeTestRule
.onNodeWithText("Email address")
.performTextInput("invalid")
composeTestRule
.onNodeWithText("Create account")
.performClick()
composeTestRule
.onNodeWithText("Enter a valid email address.")
.assertExists()Avoid testing a particular border color or implementation-specific state mutation. Test the behavior a user can observe: the error appears at the right time, explains the issue, clears after correction, and prevents an invalid submission.
Common mistakes
Validating remotely on every keystroke
Debounce a remote check or wait for blur/submit. Keep local rules immediate and network state explicit so users can continue editing while availability is checked.
Showing every error before the user interacts
An untouched form full of red errors feels broken. Track interaction or submission state and reveal errors progressively.
Hiding the message behind a red field
Use supporting text or an error summary with actionable language: “Password must contain 12 characters,” not “Invalid input.”
Losing values after a configuration change
Screen state belongs in a state holder that can restore the appropriate form values. Do not keep the only copy inside a leaf field.
FAQ
Should validation happen in the composable?
No. The composable renders the error and forwards events. Keep validation logic in a ViewModel, use case, or dedicated validator so it can be tested without Compose.
Should I disable the submit button?
Usually, yes, when locally required data is clearly incomplete or a request is in flight. Still run validation when submitted, because the state may change or be restored in unexpected ways.
How do I validate password confirmation?
Treat it as a cross-field rule. Re-evaluate the confirmation when either password changes and show a concise mismatch message at a helpful time.
Summary
Good Compose validation is explicit state, useful timing, and clear recovery messages. Keep values and rules out of the field composable, show errors in text as well as color, validate again on submit, and test both pure rules and visible UI behavior.