zema_forms
Installation
# pubspec.yaml
dependencies:
zema: ^0.5.0
zema_forms: ^0.1.0
import 'package:zema/zema.dart';
import 'package:zema_forms/zema_forms.dart';
Core concepts
ZemaFormController
ZemaFormController<T> is the single source of truth for a form. It accepts a ZemaObject<T> schema and manages:
- One
TextEditingControllerper field, accessed viacontrollerFor(field). - One
ValueNotifier<List<ZemaIssue>>per field, accessed viaerrorsFor(field).
Each field widget subscribes only to its own notifier. A validation failure on email rebuilds only the email widget. No other field is touched.
final _schema = z.object({
'email': z.string().email(),
'password': z.string().min(8),
});
late final _ctrl = ZemaFormController(schema: _schema);
@override
void dispose() {
_ctrl.dispose(); // releases all TextEditingControllers and ValueNotifiers
super.dispose();
}
ZemaTextField
ZemaTextField is a StatefulWidget that wraps a Flutter TextField. It resolves the controller from an explicit controller parameter or from the nearest ZemaForm ancestor.
ZemaTextField(
field: 'email',
controller: _ctrl,
decoration: const InputDecoration(labelText: 'Email'),
)
Error text is managed internally. When errorsFor('email') fires, only this widget rebuilds and InputDecoration.errorText is updated with issues.first.message.
ZemaForm
ZemaForm is an InheritedWidget scope that provides the controller to all descendants. It is optional. It never triggers rebuilds: updateShouldNotify always returns false.
ZemaForm(
controller: _ctrl,
child: Column(
children: [
ZemaTextField(field: 'email'), // resolves _ctrl from scope
ZemaTextField(field: 'password'),
],
),
)
Without ZemaForm, pass the controller explicitly to each field.
Complete example
final _schema = z.object({
'email': z.string().email(),
'password': z.string().min(8),
});
class LoginForm extends StatefulWidget {
const LoginForm({super.key});
@override
State<LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
late final _ctrl = ZemaFormController(schema: _schema);
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
void _onSubmit() {
final data = _ctrl.submit();
if (data != null) {
api.login(data['email'] as String, data['password'] as String);
}
}
@override
Widget build(BuildContext context) {
return ZemaForm(
controller: _ctrl,
child: Column(
children: [
ZemaTextField(
field: 'email',
decoration: const InputDecoration(labelText: 'Email'),
keyboardType: TextInputType.emailAddress,
),
ZemaTextField(
field: 'password',
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
),
ElevatedButton(
onPressed: _onSubmit,
child: const Text('Sign in'),
),
],
),
);
}
}
Error visibility: "first contact" UX
Errors are validated internally on every keystroke but are only shown when:
(isTouched || isSubmitted) && errors.isNotEmpty
A field becomes touched when it loses focus for the first time. This prevents showing "Email invalide" after the user types a single character. The error appears when the user leaves the field, or when they click Submit.
ZemaTextField manages a FocusNode internally and calls markTouched(field) on blur automatically. No extra setup is required.
Submit: auto-focus on first error
When submit() fails, the controller moves focus to the first field in error (in schema declaration order) automatically. The user does not need to scroll or find the error manually.
This requires ZemaTextField to be used (not TextFormField). ZemaTextField registers its FocusNode with the controller during initState and unregisters on dispose.
Canonical error priority
Focus is attempted on the first field in schema declaration order that has an error, regardless of whether that field is currently visible in the UI (inside a collapsed accordion, a non-selected tab, a conditional section).
If the field is not mounted (FocusNode.canRequestFocus is false), the focus request is silently skipped. The controller does not fall through to the next error field.
This is intentional. Jumping to the second error field would let the user fix a lower-priority error while the first error remains hidden, leaving the form in a permanently invalid state they cannot explain. Silent skip is preferable to an arbitrary jump.
Consequence for UI design: when a form has fields that can be hidden, always pair submit() with a form-level error banner (see below). The banner tells the user something is wrong even when the auto-focus cannot land.
Form-level error banner
ZemaFormController.submitErrors is a ValueNotifier<List<ZemaIssue>> that holds all issues from the most recent failed submit() call. It is empty before the first submission, after a successful submit(), and after reset().
This is the correct signal for a form-level banner when the first error field may not be visible:
ValueListenableBuilder<List<ZemaIssue>>(
valueListenable: _ctrl.submitErrors,
builder: (context, issues, _) {
if (issues.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(12),
color: Colors.red.shade50,
child: Text(
'${issues.length} field(s) require attention.',
style: const TextStyle(color: Colors.red),
),
);
},
)
The banner clears automatically when submit() succeeds or reset() is called. No manual state management is needed.
Validation modes
ZemaFormController validates on each keystroke by default. Errors remain hidden until the field is touched or submit() is called. To skip per-keystroke validation entirely:
ZemaFormController(
schema: _schema,
validateOnChange: false,
)
With validateOnChange: false, validation only runs on submit(). The touched/submitted visibility rules still apply.
Pre-populating fields
Pass initialValues to the constructor:
ZemaFormController(
schema: _schema,
initialValues: {'email': currentUser.email},
)
Or set values after construction:
_ctrl.setValue('email', currentUser.email);
Form-mode bridge
To integrate with Flutter's built-in Form widget without migrating to ZemaTextField:
Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _ctrl.controllerFor('email'),
validator: _ctrl.validatorFor('email'),
),
TextFormField(
controller: _ctrl.controllerFor('password'),
validator: _ctrl.validatorFor('password'),
obscureText: true,
),
],
),
)
validatorFor(field) returns String? Function(String?), the exact signature TextFormField.validator expects.
Non-string fields
TextField always produces a String. For numeric fields, use the coercion layer:
z.object({
'age': z.coerce().integer(min: 0, max: 150),
'price': z.coerce().decimal(),
})
z.coerce().integer() receives the raw string and converts it to int internally. If the string is not parseable, the schema fails with invalid_type. Using z.integer() (without coerce) on a text field always fails because the field sends a String.
Performance
Surgical rebuilds
ZemaFormController owns one ValueNotifier<List<ZemaIssue>> per field. Each ZemaTextField subscribes only to its own notifier via ValueListenableBuilder. When email fails, only the email widget rebuilds. The password widget never calls build.
A naive implementation would use a single notifier for all errors — every keystroke anywhere would rebuild every field. zema_forms avoids this entirely.
Schema reuse
Define schemas at file scope, not inside State.build. Schema allocation is cheap, but rebuilding on every frame is wasteful.
// Good: one allocation, reused for every form instance
final _loginSchema = z.object({'email': z.string().email()});
class _LoginFormState extends State<LoginForm> {
late final _ctrl = ZemaFormController(schema: _loginSchema);
}
Controller lifecycle
Create the controller with late final in State. Always call dispose(). Flutter's LeakTracker (enabled in debug builds from Flutter 3.18) reports leaked TextEditingController listeners as memory leaks.
ZemaForm overhead
ZemaForm.of(context) calls getInheritedWidgetOfExactType, which is an O(1) map lookup. There is no overhead from using ZemaForm over passing the controller explicitly.
API reference
| Member | Type | Description |
|---|---|---|
schema | ZemaObject<T> | The schema that drives validation |
validateOnChange | bool | Validate on each keystroke (default: true) |
controllerFor(field) | TextEditingController | Lazy-created text controller for the field |
errorsFor(field) | ValueNotifier<List<ZemaIssue>> | Per-field error notifier |
touchedFor(field) | ValueNotifier<bool> | true once the field has lost focus |
markTouched(field) | void | Force-mark a field as touched programmatically |
isSubmitted | ValueNotifier<bool> | true after the first submit() call |
submitErrors | ValueNotifier<List<ZemaIssue>> | All issues from the last failed submit(); empty on success or reset |
submit() | T? | Validate all fields; auto-focus first error field; returns typed output or null |
validatorFor(field) | String? Function(String?) | Form-mode bridge for TextFormField.validator |
setValue(field, value) | void | Set field text programmatically |
hasErrors | bool | true when any field currently has validation errors |
reset() | void | Clear all text, errors, touched state, and submitErrors |
dispose() | void | Release all controllers and notifiers |