Recipe: A form with validation and submit¶
Problem: a form has fields, per-field validation, a submit button that’s only enabled when the form is valid, and a submitting state.
Put each field’s value, error, and validation into a small presentation state holder (the same idea
as EmailFieldState in Scaling Presenters).
The holder owns the Compose state and validates on every change; the presenter creates one per field
with rememberRetained and reads from them.
The field holder¶
A reusable @Stable holder keeps the value/error/validity together. onValueChange()
re-validates.
@Stable
class FieldState(private val validate: (String) -> String?) {
var value by mutableStateOf("")
private set
var error by mutableStateOf<String?>(null)
private set
val isValid: Boolean get() = value.isNotEmpty() && error == null
fun onValueChange(newValue: String) {
value = newValue
error = validate(newValue)
}
private fun validate(newValue: String): String? {
// returns an error message or null, so each field can use its own rule
}
}
The state and events¶
State exposes each field’s value and error, plus canSubmit and isSubmitting. Events are “this
field changed” and “submit”.
@Stable
data class SignUpState(
val email: String,
val emailError: String?,
val password: String,
val passwordError: String?,
val canSubmit: Boolean,
val isSubmitting: Boolean,
val eventSink: (SignUpEvent) -> Unit,
) : CircuitUiState
@Immutable
sealed interface SignUpEvent : CircuitUiEvent {
data class EmailChanged(val value: String) : SignUpEvent
data class PasswordChanged(val value: String) : SignUpEvent
data object Submit : SignUpEvent
}
The presenter¶
Create a FieldState per field with rememberRetained so in-progress input survives rotation and
the back stack. Derive canSubmit from the holders instead of storing it separately.
Keep account creation in the data layer if it must persist through config changes or navigation. The presenter triggers the request and observes its in-flight state like any other repository state.
@Composable
override fun present(): SignUpState {
val email = rememberRetained { FieldState(::validateEmail) }
val password = rememberRetained { FieldState(::validatePassword) }
// accountRepository owns the submission; the presenter observes whether it is in flight.
val submitting by produceRetainedState(initialValue = false) {
accountRepository.signUpInFlight.collect { inFlight -> value = inFlight }
}
val canSubmit = email.isValid && password.isValid && !submitting
return SignUpState(
email = email.value,
emailError = email.error,
password = password.value,
passwordError = password.error,
canSubmit = canSubmit,
isSubmitting = submitting,
) { event ->
when (event) {
is SignUpEvent.EmailChanged -> email.onValueChange(event.value)
is SignUpEvent.PasswordChanged -> password.onValueChange(event.value)
// The repository launches the work on its own scope and updates signUpInFlight.
SignUpEvent.Submit -> if (canSubmit) accountRepository.signUp(email.value, password.value)
}
}
}
private fun validateEmail(value: String): String? =
if (value.isNotEmpty() && !value.isValidEmail()) "Enter a valid email" else null
private fun validatePassword(value: String): String? =
if (value.isNotEmpty() && value.length < 16) "At least 16 characters" else null
The UI¶
Binds values, surfaces errors, gates the button on canSubmit:
OutlinedTextField(
value = state.email,
onValueChange = { value -> state.eventSink(SignUpEvent.EmailChanged(value)) },
isError = state.emailError != null,
supportingText = { state.emailError?.let { error -> Text(error) } },
)
Button(onClick = { state.eventSink(SignUpEvent.Submit) }, enabled = state.canSubmit) {
if (state.isSubmitting) CircularProgressIndicator() else Text("Sign up")
}
The holder keeps validation close to the field value, and canSubmit is computed from the holders.
Adding another field is one more rememberRetained { FieldState(...) }. For more on where to launch
suspend work, see run a suspend action from an event.
See also: Scaling Presenters: state holders · States and Events · Run a suspend action from an event