Skip to content

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.