Circuit
Recommended wiring: the validator lives in the presenter.
Circuit presenters are composable functions, so
remember { FieldValidator(...) } works there naturally — the presenter owns the form
state, and the UI stays a dumb renderer of CircuitUiState.
State and events
/** Circuit state: validators ride along as @Stable references; events flow back via the sink. */
data class SignUpState(
val email: FieldValidator<String>,
val password: FieldValidator<String>,
val submitted: Boolean?,
val eventSink: (SignUpEvent) -> Unit,
) : CircuitUiState
sealed interface SignUpEvent : CircuitUiEvent {
data object Submit : SignUpEvent
}
The presenter
class SignUpPresenter : Presenter<SignUpState> {
@Composable
override fun present(): SignUpState {
// Circuit presenters are composable functions, so `remember` keeps the
// validators (and the in-flight draft) across recompositions.
val email = remember { FieldValidator("", rules = listOf(Required, Email)) }
val password = remember { FieldValidator("", rules = listOf(Required, MinLength(8))) }
var submitted by remember { mutableStateOf<Boolean?>(null) }
return SignUpState(email, password, submitted) { event ->
when (event) {
// Form-level validate(): shows errors on every field, true when all pass.
SignUpEvent.Submit -> submitted = listOf(email, password).validate()
}
}
}
}
The UI
@Composable
fun SignUpUi(state: SignUpState, modifier: Modifier = Modifier) {
Column(modifier) {
ValidatedField(state.email, label = "Email")
ValidatedField(state.password, label = "Password")
Button(
onClick = { state.eventSink(SignUpEvent.Submit) },
modifier = Modifier.fillMaxWidth(),
) { Text("Create account") }
state.submitted?.let { Text(if (it) "Valid — proceeding" else "Fix the errors above") }
}
}
@Composable
private fun ValidatedField(field: FieldValidator<String>, label: String) {
OutlinedTextField(
value = field.value,
onValueChange = field::onValueChange,
label = { Text(label) },
isError = field.state.isError,
supportingText = field.state.supportingText(),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.onFocusLost { field.onFocusLost() },
)
}
Hosting it
/**
* Minimal host. In a real app you register SignUpPresenter/SignUpUi in a Circuit via
* Presenter.Factory + Ui.Factory and render through CircuitContent(screen); a routed
* Screen needs @Parcelize on Android, which is app-level wiring beyond validation —
* so we keep it out of this example.
*/
@Composable
fun SignUpCircuitHost() {
val presenter = remember { SignUpPresenter() }
SignUpUi(presenter.present())
}
What this example leaves out
A real app routes via a Screen + CircuitContent, registering the presenter and
UI through Presenter.Factory / Ui.Factory. Screen is Parcelable on Android
(@Parcelize plumbing), which is app-level wiring unrelated to validation — so the
example hosts the presenter directly. Only circuit-foundation is needed for
everything shown here, and rememberRetained is a drop-in upgrade for remember
if you want the validators to survive Circuit's retention scope.
Prefer immutable state throughout? The MVI value-hoist wiring drops into a
Circuit presenter unchanged — fold rules with toFieldState and put
FieldValidationState in your CircuitUiState instead of the validator.