Ikokuko
Reactive, type-safe form validation for Compose Multiplatform (Android & iOS)
Install / Use
/learn @quantipixels/IkokukoREADME
ìkọkúkọ
Reactive, type-safe form validation for Compose Multiplatform (Android & iOS)
Build declarative, cross-platform forms that validate themselves as users type.
Features
- 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.
- Platform-agnostic – works on Android, Desktop, iOS, JS, and Wasm.
- Built-in validators – text, numeric, pattern, equality, selection.
- Extendable – implement your own
Validator<T>easily.
Getting Started
1. Add dependency
repositories {
mavenCentral()
}
dependencies {
implementation("com.quantipixels:ikokuko:0.1.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.
Optionally pass shouldShowErrors when creating the state to control its initial error visibility behavior.
// Default: errors hidden until submit or manual toggle
val formState = remember { FormState() }
// Errors become visible after submit or as fields change (dirty)
val formState = remember { FormState(shouldShowErrors = true) }
shouldShowErrors
Controls when validation errors are globally visible.
|Value|Behaviour|Typical Use Case|
|---|---|---|
|false (default)|Validation runs continuously, but errors are hidden until submit() or manual toggle.|Most common — errors appear only after first submit.|
|true|Errors become visible once a field value changes (becomes dirty) or after submit.|Used when you want validation messages to show immediately upon interaction.|
You can toggle this flag at any time from either the FormState or inside the FormScope.
// From FormState
formState.shouldShowErrors = true // Show all validation errors
formState.shouldShowErrors = false // Hide errors again
// From FormScope
Form(onSubmit = {}) {
// ...
shouldShowErrors = true // Show all validation errors
shouldShowErrors = false // Hide errors again
}
Resetting the form
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") }
}
How fields work
Fieldinstances are identified by their name, not by object identity.- 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.
Name-based behavior
|Case|Behaviour| |---|---| |Same name, same type|Fields share the same value in the FormState. Updating one updates them all.| |Same name, different type|Causes a crash when FormScope tries to cast the stored value back to the wrong type.| |Different names|Fields maintain independent values and validation states.|
Recommended
Always ensure that all form fields have unique names within a single
FormScope.
4. Add Validation and Connect Fields to the FormState
You can connect fields to your FormState and enable validation in two ways:
- Manual setup — call ValidationEffect directly to register and validate a field.
Form(onSubmit={ println("Email: ${EmailField.value}") }) {
ValidationEffect(
field = EmailField,
default = "",
validators = listOf(
RequiredValidator("Email required"),
EmailValidator("Invalid email")
)
)
OutlinedTextField(
value = EmailField.value,
isError = !EmailField.isValid,
label = { Text("Email") },
supportingText = EmailField.error?.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,
default = "",
validators = listOf(
RequiredValidator("Email required"),
EmailValidator("Invalid email")
)
) {
OutlinedTextField(
value = EmailField.value,
isError = !EmailField.isValid,
label = { Text("Email") },
supportingText = EmailField.error?.let {
{ Text(it, color = MaterialTheme.colorScheme.error) }
},
onValueChange = { EmailField.value = it }
)
}
5. Overriding Errors Manually
Each Field exposes an error property that represents its current validation error message, and it can be set or cleared manually at any time.
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.
Example: Manual error assignment
// 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
6. Creating Reusable Form Components
ì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.
Example: TextInput
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.isValid,
label = { Text(label) },
placeholder = {
Text(
placeholder,
color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f)
)
},
supportingText = field.error?.let { { Text(it) } },
onValueChange = { field.value = it },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(12.dp))
}
}
}
How it works
ValidationEffectattaches validators and ensures the field’s value and errors stay reactive.field.valuebinds the text input to the form state.field.errorprovides the active error message when visible.field.isValiddrives the error styling (isError = !field.isValid).
All form logic is encapsulated inside the FormScope, so the field automatically integrates with submit(), reset(), and global validation visibility.
7. Build a form
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 th
Related Skills
node-connect
347.6kDiagnose OpenClaw node connection and pairing failures for Android, iOS, and macOS companion apps
frontend-design
108.4kCreate distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
openai-whisper-api
347.6kTranscribe audio via OpenAI Audio Transcriptions API (Whisper).
qqbot-media
347.6kQQBot 富媒体收发能力。使用 <qqmedia> 标签,系统根据文件扩展名自动识别类型(图片/语音/视频/文件)。