Zema Logo
Plugins

zema_forms

Bind ZemaObject schemas to Flutter form widgets with surgical per-field rebuilds and typed output.

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 TextEditingController per field, accessed via controllerFor(field).
  • One ValueNotifier<List<ZemaIssue>> per field, accessed via errorsFor(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

MemberTypeDescription
schemaZemaObject<T>The schema that drives validation
validateOnChangeboolValidate on each keystroke (default: true)
controllerFor(field)TextEditingControllerLazy-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)voidForce-mark a field as touched programmatically
isSubmittedValueNotifier<bool>true after the first submit() call
submitErrorsValueNotifier<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)voidSet field text programmatically
hasErrorsbooltrue when any field currently has validation errors
reset()voidClear all text, errors, touched state, and submitErrors
dispose()voidRelease all controllers and notifiers
Copyright © 2026