Login Form with Validation and Single-Submit
EasyForms · ComposeOne request per submit
The problem
Build a login screen with email and password.
Requirements:
- Inline validation, shown after the user leaves a field — not while they are still typing the first character.
- The button is disabled until the form is valid, and while the request is running.
- A double tap must not send two requests.
- Show a server error, and keep what the user typed.
Stuck? Open one hint
Every rule here is a property you can derive from state. Derive, do not store.
Show the worked solution
Small problem, and it is deliberately a filter: it checks whether you create a tangle of booleans or a clean state object.
- Hold
email,password,emailTouched,passwordTouched,isSubmitting,serverError. - Derive
emailError,passwordErrorandcanSubmit— do not store them, or they will drift. - Show an error only when the field is touched or a submit was attempted.
- The single-submit guard is
if (isSubmitting) returnat the top ofsubmit(), plus the disabled button. Both, because the button can be tapped twice before the first recomposition lands.
Mention accessibility and keyboard behaviour if there is time: correct keyboard type for email, next/done IME actions, and a password visibility toggle.
KotlinOne request per submit
data class LoginState(
val email: String = "",
val password: String = "",
val emailTouched: Boolean = false,
val passwordTouched: Boolean = false,
val isSubmitting: Boolean = false,
val serverError: String? = null,
) {
val emailError: String?
get() = when {
!emailTouched -> null
email.isBlank() -> "Email is required"
!email.matches(Regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")) -> "Enter a valid email"
else -> null
}
val passwordError: String?
get() = when {
!passwordTouched -> null
password.length < 8 -> "At least 8 characters"
else -> null
}
val canSubmit: Boolean
get() = email.isNotBlank() && password.length >= 8 &&
emailError == null && passwordError == null && !isSubmitting
}
fun submit() {
if (_state.value.isSubmitting || !_state.value.canSubmit) return // double-tap guard
_state.update { it.copy(isSubmitting = true, serverError = null) }
viewModelScope.launch {
runCatching { auth.login(_state.value.email, _state.value.password) }
.onFailure { e -> _state.update { it.copy(serverError = e.message) } }
_state.update { it.copy(isSubmitting = false) }
}
}