Reactive, type-safe form validation for Compose Multiplatform (Android & iOS)
Build declarative, cross-platform forms that validate themselves as users type.
- Lightweight – no reflection or annotation processors.
- Compose-first – integrates naturally with Compose Multiplatform UIs.
- Reactive validation – runs automatically when field values change.
- Type-safe fields –
Field<T>enforces consistent types. - Composable DSL – define forms and validators declaratively.
- Multiplatform – supports Android and iOS.
- Built-in validators – text, numeric, pattern, equality, selection.
- Extendable – implement your own
Validator<T>easily.
repositories {
mavenCentral()
}
dependencies {
implementation("com.quantipixels:ikokuko:0.2.0")
}2. FormState
FormState manages all field values, validation errors, and visibility flags for a form.
It’s the single source of truth for the form’s current state.
FormState.isValid follows the form's error-reporting state. It remains true while
shouldShowErrors is false. After error reporting is enabled, it is false while any field has
a stored error. Individual field.isValid values remain strict regardless of error visibility.
Optionally pass
shouldShowErrorswhen creating the state to control its initial error visibility behavior.
// Default: dirty-field errors are hidden until submit or a manual toggle
val formState = remember { FormState() }
// Dirty-field errors can be displayed immediately
val formState = remember { FormState(shouldShowErrors = true) }Use rememberSaveableFormState() when field values must survive supported platform state restoration:
val formState = rememberSaveableFormState()The default saver supports values accepted by the platform save registry. Form keeps its default
state in memory because Field<T> can contain any non-null type. For custom field values, pass a
complete Saver<FormState, out Any>. You can also use FormState.saver(valuesSaver) to provide a
saver for the complete Map<String, Any> of field names and values while Ikokuko saves the remaining
form state.
Controls whether stored errors can be displayed for dirty fields. A pristine field does not display
its error until it becomes dirty. submit() marks all initialized fields as dirty.
| Value | Behaviour | Typical Use Case |
|---|---|---|
false (default) |
Validation runs continuously, but dirty-field errors remain hidden and FormState.isValid remains true. |
Use when errors must first affect the UI after submit() or a manual toggle. |
true |
Stored errors affect FormState.isValid and are visible for dirty fields. Pristine-field errors remain hidden. |
Use when validation must affect the UI as fields change. |
You can toggle this flag at any time from either the FormState or inside the FormScope.
// From FormState
formState.shouldShowErrors = true // Permit dirty-field errors
formState.shouldShowErrors = false // Hide errors again
// From FormScope
Form(onSubmit = {}) {
// ...
shouldShowErrors = true // Permit dirty-field errors
shouldShowErrors = false // Hide errors again
}The form can be reset from either the FormState or inside the FormScope.
// From FormState
formState.reset()
// From FormScope
Form(onSubmit = {}) {
// ...
Button(onClick = ::reset) { Text("Reset Form") }
}3. Defining Fields
You can define a Field using either typed constructors or generic syntax, depending on your use case and desired type safety.
- Typed constructors (recommended for readability) — ìkọkúkọ provides convenience factory functions for the most common field types:
val EmailField = Field.Text("email")
val RememberMeField = Field.Boolean("remember_me")
val RangeField = Field.Range("price_range") // ClosedFloatingPointRange<Float>- Generic field syntax (for custom or advanced cases) — You can also define a
Fielddirectly with its type parameter:
val NameField = Field<String>("name")
val CustomField = Field<MyCustomData>("custom")You can define Field objects as
- top-level (or global),
- local, or
- composable-scoped values — they’re lightweight and can be freely recreated.
// top-level (or global)
val EmailField = Field.Text("email")
@Composable
fun DemoForm() {
// local — recreated on every recomposition (fine for stateless forms)
val emailField = Field.Text("email")
// composable-scoped — stable across recompositions
val emailField = remember { Field.Text("email") }
}Fieldinstances are identified by their name, not by object identity.Field<T>requires a non-nullT. Use an explicit value such as an empty string or list to represent no input.- You can safely recreate them on each composition — their state in the form will persist as long as the name stays the same.
Fieldobjects are cheap to construct; there’s no need to remember them unless you prefer stable references.
| Case | Behaviour |
|---|---|
| Same name, same type | Fields share the same value in the FormState. Updating one updates them all. |
| Same name, different type | Fields share one map entry because generic types do not affect field equality. Reading the entry through the wrong type can cause a runtime cast error. |
| Different names | Fields maintain independent values and validation states. |
Each field name must be unique within a
FormScope.ValidationEffectdoes not check for duplicate names because it cannot distinguish a recreated logical field from a second declaration.
You can connect fields to your FormState and enable validation in two ways:
Use one ValidationEffect or FormField for each field in a form.
- Manual setup — call ValidationEffect directly to register and validate a field.
Form(onSubmit={ println("Email: ${EmailField.value}") }) {
ValidationEffect(
field = EmailField,
initialValue = "",
validators = listOf(
RequiredValidator("Email required"),
MatchPatternValidator("Invalid email", "[^@\\s]+@[^@\\s]+\\.[^@\\s]+")
)
)
OutlinedTextField(
value = EmailField.value,
isError = EmailField.shouldDisplayError,
label = { Text("Email") },
supportingText = EmailField.error.takeIf { EmailField.shouldDisplayError }?.let {
{ Text(it, color = MaterialTheme.colorScheme.error) }
},
onValueChange = { EmailField.value = it }
)
}- Convenience setup — use FormField, which automatically registers the field and runs validation on value changes.
FormField(
field = EmailField,
initialValue = "",
validators = listOf(
RequiredValidator("Email required"),
MatchPatternValidator("Invalid email", "[^@\\s]+@[^@\\s]+\\.[^@\\s]+")
)
) {
OutlinedTextField(
value = EmailField.value,
isError = EmailField.shouldDisplayError,
label = { Text("Email") },
supportingText = EmailField.error.takeIf { EmailField.shouldDisplayError }?.let {
{ Text(it, color = MaterialTheme.colorScheme.error) }
},
onValueChange = { EmailField.value = it }
)
}Each Field exposes a raw error property that represents its current validation error message. It can be set or cleared manually at any time. Use shouldDisplayError to decide whether to render it.
var Field<*>.error: String?Normally, this value is updated automatically by ValidationEffect whenever validators fail, but you can override it manually for advanced use cases such as:
- Server-side or asynchronous validation (e.g. username already taken).
- Custom inline validation not covered by existing Validator classes.
- Resetting or clearing errors programmatically.
// Inside a FormScope
// Assign error message
if (EmailField.value.endsWith("@test.com")) {
EmailField.error = "Test domains are not allowed"
}
// Clear the error message
EmailField.error = nullìkọkúkọ’s FormScope lets you build reusable composable form components that automatically handle value binding, validation, and error display. This makes it easy to define input fields once and reuse them across different forms.
You can create a reusable text input field as an extension on FormScope:
@Composable
fun FormScope.TextInput(
field: Field<String>,
modifier: Modifier = Modifier,
initialValue: String = "",
label: String = "",
placeholder: String = "",
validators: List<Validator<String>> = emptyList()
) {
FormField(field, initialValue, validators) {
Column(modifier = modifier) {
OutlinedTextField(
value = field.value,
isError = field.shouldDisplayError,
label = { Text(label) },
placeholder = {
Text(
placeholder,
color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f)
)
},
supportingText = field.error.takeIf { field.shouldDisplayError }?.let { { Text(it) } },
onValueChange = { field.value = it },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(12.dp))
}
}
}ValidationEffectattaches validators and ensures the field’s value and errors stay reactive.field.valuebinds the text input to the form state.field.errorprovides the raw active error message.field.isValidreports strict validation state.field.shouldDisplayErrordrives error styling and message visibility.
All form logic is encapsulated inside the FormScope, so the field automatically integrates with submit(), reset(), and global validation visibility.
Compose your complete form by combining your defined fields, inputs, and validators inside a Form. The Form automatically manages field registration, validation, and submission through a shared FormState. It also supports cross-field validation, allowing validators to depend on the values of other fields (e.g. password confirmation, date ranges, matching inputs).
💡 This example builds on the reusable
TextInputcomponent described in the previous section — each input is already wired to its corresponding Field and validation logic.
val EmailField = Field.Text("email")
val PasswordField = Field.Text("password")
val ConfirmPasswordField = Field.Text("confirm_password")
@Composable
fun SignUpForm() {
val formState = remember { FormState() }
Form(state = formState, onSubmit = {
println("Email: ${EmailField.value}")
println("Password: ${PasswordField.value}")
println("Password Confirmation: ${ConfirmPasswordField.value}")
}) {
Column {
TextInput(
field = EmailField,
label = "Email",
validators = listOf(
RequiredValidator("Email required"),
MatchPatternValidator("Invalid email", "[^@\\s]+@[^@\\s]+\\.[^@\\s]+")
)
)
TextInput(
field = PasswordField,
label = "Password",
validators = listOf(
RequiredValidator("Password required"),
MinLengthValidator("At least 8 characters", 8)
)
)
// Cross-field validation
// FieldEqualsValidator declares PasswordField as a dependency.
TextInput(
field = ConfirmPasswordField,
label = "Password Confirmation",
validators = listOf(
RequiredValidator("password confirmation is required"),
FieldEqualsValidator("passwords must match", PasswordField)
)
)
Button(onClick = ::submit, enabled = isValid) {
Text("Sign In")
}
}
}
}- Uses the
TextInputreusable component defined in the previous section. FormStatetracks and validates all registered fields automatically.- The
onSubmitcallback executes only when all validations pass. - Cross-field validators declare the fields they read. A dependency value change revalidates the target field.
- The form
isValidproperty remainstrueuntil error reporting is enabled. After the firstsubmit(), stored errors can disable UI elements such as this button. submit()uses the latest completed reactive validation cycle. Do not assign a field value and callsubmit()synchronously in the same callback.
8. Built-in Validators
| Validator | Description |
|---|---|
RequiredValidator |
Must not be blank |
MinLengthValidator |
Minimum characters |
MaxLengthValidator |
Maximum characters |
LengthValidator |
Exact length |
| Validator | Description |
|---|---|
MinValidator |
≥ min |
MaxValidator |
≤ max |
RangeValidator |
Between min and max |
| Validator | Description |
|---|---|
MatchPatternValidator |
Entire string matches a pattern string |
ContainsPatternValidator |
Pattern string occurs anywhere |
| Validator | Description |
|---|---|
FieldEqualsValidator |
Must equal another field value |
InValidator |
Value must be in the allowed set |
NotInValidator |
Value must not be in the disallowed set |
| Validator | Description |
|---|---|
SelectionRangeValidator |
Minimum and optional maximum item count |
SelectionInValidator |
Ensures all selected values are within the allowed options |
| Validator | Description |
|---|---|
CheckedValidator |
Must be true |
Implement the Validator<T> interface:
data class StartsWithValidator(
override val errorMessage: String,
private val prefix: String
) : Validator<String> {
override fun ValidationScope.validate(value: String) = value.startsWith(prefix)
}Use it normally:
ValidationEffect(
field = UsernameField,
initialValue = "",
validators = listOf(StartsWithValidator("Must start with @", "@"))
)ValidationEffect uses the validator list as an effect key. Built-in validators are data
classes so an equivalent inline validator remains equal across recompositions. This prevents
validation from restarting when no validation rule changed.
Custom validators used inline should also have structural equality. A data class is the simplest option. Avoid lambda-backed validators. A new lambda normally has a new identity on each recomposition, even when it has the same behavior. This can restart validation and replace an external error without a field or dependency value change.
If a custom validator must contain a lambda, remember the validator instance:
val validator = remember {
CustomValidator("Invalid value") { value -> checkValue(value) }
}Declare every field read during validation in dependencies.
- Change nullable field types to non-null types.
Field<T>now requiresT : Any. - Remove field destructuring and replace the removed
Field.Int,Field.Long, andField.Doublefactories with the genericField<T>(name)constructor when required. - Give each field a unique name within its
FormScope. Same-name fields share one stored value. - Rename the
defaultargument ofValidationEffectandFormFieldtoinitialValue. - Replace
field.markAsDirty()withfield.isDirty = true. TheisDirtyproperty is now writable. - Remove explicit
nullarguments fromsubmit(onInvalid). Its fallback is now a non-null no-op callback. - Use
field.shouldDisplayErrorfor rendering.field.errorandfield.isValidexpose strict stored field state.FormState.isValidignores stored errors until error reporting is enabled. - Update custom validators to implement
fun ValidationScope.validate(value: T). Declare each field read by validation independencies. - Use stable structural equality for inline custom validators. Prefer data classes, and remember validators that contain lambdas.
- Replace numeric transform lambdas with the string-backed integer
MinValidator,MaxValidator, andRangeValidatorconstructors. These validators parse values withtoIntOrNull(). - Pass pattern strings instead of
Regexobjects toMatchPatternValidatorandContainsPatternValidator. - Replace
EmailValidatorandPhoneNumberValidatorwithMatchPatternValidatorand an application-owned pattern. - Replace
EqualsValidatorwithFieldEqualsValidatorfor field comparison. Implement fixed-value equality, inequality, and other uncommon rules as custom validators. - Replace
NonEmptySelectionValidator,MinSelectionValidator,MaxSelectionValidator, andExactSelectionValidatorwithSelectionRangeValidator. Itsmaxargument can benullfor an unbounded maximum. - Use
CheckedValidatorfor required Boolean fields. UseInValidator,NotInValidator, andSelectionInValidatorfor membership rules. - Account for reset behavior.
reset()now clears values, errors, dirty state, and error visibility, then revalidates initialized effects.
Demo - Sample App
See Ikokuko — the reactive, type-safe form validation library for Compose Multiplatform (Android & iOS) — in action:
itokuto.demo.mp4
This short video showcases real-time validation and error handling using Ikokuko in a Compose Multiplatform sample app.
Copyright 2025 Quanti Pixels
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
