Zard Flutter
Reactive, headless-first forms for Flutter β powered by Zard schemas.
Zod + React Hook Form ergonomics, native to Flutter. Your schema is the single source of truth for validation, transformation, and types.
π§π· DocumentaΓ§Γ£o em PortuguΓͺs (pt-BR)
Support π
If you find Zard Flutter useful, please consider supporting its development π Buy Me a Coffee π. Your support helps us improve the framework and make it even better!
Why Zard Flutter π€
Zard already gives you schemas, validation, transforms, and typed output. Zard Flutter adds the Flutter layer on top: form state, per-field state, granular reactivity, and a set of widgets to bind it all to your UI β without forcing a particular look.
- Headless-first β
ZardForm/ZardFieldcarry behavior, not pixels. Drop in your own widgets, or use the included Material set. - Granular subscriptions β per-field listeners mean typing in one field doesn't rebuild the others.
- One schema, everywhere β validation, async checks, transforms, and typed output all come from your Zard schema.
- Nested paths & field arrays β
user.address.street, dynamic lists with stable row IDs. - Async validation β debounced, per-field, with loading indicators.
- Hooks are optional β use the
useFormhook style, or plainZardFormControllerwith noflutter_hooksdependency.
Installation π¦
dependencies:
zard: ^1.1.2
zard_flutter: ^1.0.0-beta.1
# Optional β only needed if you use the hooks layer (`package:zard_flutter/hooks.dart`).
flutter_hooks: ^0.20.5
flutter pub get
Imports you'll use:
import 'package:zard/zard.dart'; // z.map, z.string, ...
import 'package:zard_flutter/zard_flutter.dart'; // ZardForm, ZardField, ZardInput, ...
import 'package:zard_flutter/hooks.dart'; // useForm, useWatch, ... (optional)
Quick start π
A minimal login form, end to end:
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:zard/zard.dart';
import 'package:zard_flutter/hooks.dart';
import 'package:zard_flutter/zard_flutter.dart';
final _loginSchema = z.map({
'email': z.string().email(message: 'Enter a valid email'),
'password': z.string().min(6, message: 'At least 6 characters'),
});
class LoginScreen extends HookWidget {
const LoginScreen({super.key});
@override
Widget build(BuildContext context) {
final form = useForm(
schema: _loginSchema,
defaultValues: const {'email': '', 'password': ''},
mode: ValidationMode.onTouched,
);
return ZardForm(
form: form,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const ZardField<String>(
name: 'email',
child: ZardInput(label: 'Email', placeholder: 'you@example.com'),
),
const SizedBox(height: 12),
const ZardField<String>(
name: 'password',
child: ZardInput(label: 'Password', obscureText: true),
),
const SizedBox(height: 16),
ZardButton(
fullWidth: true,
loading: form.isSubmitting,
onPressed: form.handleSubmit((data) async {
await Future.delayed(const Duration(milliseconds: 400));
debugPrint('Submitted: $data');
}),
child: const Text('Sign in'),
),
],
),
);
}
}
That's the whole loop: schema β ZardForm β ZardField + a widget β handleSubmit.
Two ways to create a form πͺ
Hook style β useForm
class MyForm extends HookWidget {
@override
Widget build(BuildContext context) {
final form = useForm(schema: _schema, mode: ValidationMode.onTouched);
return ZardForm(form: form, child: /* ... */);
}
}
useForm creates the controller, disposes it automatically on unmount, and subscribes the
widget to form-level changes.
β οΈ Hooks must be called inside
build(). CallinguseForm/useStateas a field initializer throwsHooks can only be called from the build method. Always declare them as locals at the top ofbuild:// β WRONG β runs when the object is constructed, outside build class MyForm extends HookWidget { final form = useForm(schema: _schema); // throws! } // β RIGHT β inside build class MyForm extends HookWidget { @override Widget build(BuildContext context) { final form = useForm(schema: _schema); ... } }
Controller style β ZardFormController (no hooks)
If you don't want flutter_hooks, own the controller in a StatefulWidget:
class MyForm extends StatefulWidget {
const MyForm({super.key});
@override
State<MyForm> createState() => _MyFormState();
}
class _MyFormState extends State<MyForm> {
late final form = ZardFormController(
schema: _schema,
defaultValues: const {'email': '', 'password': ''},
mode: ValidationMode.onTouched,
);
@override
void dispose() {
form.dispose(); // you own the lifecycle here
super.dispose();
}
@override
Widget build(BuildContext context) {
return ZardForm(form: form, child: /* ... */);
}
}
Wrap any reactive part (like a submit button) in AnimatedBuilder(animation: form, ...) to
rebuild it on form-level changes.
Core concepts π§
ZardForm
Provides the controller to descendants via context. Two modes:
// 1. Bring your own controller (hook or StatefulWidget)
ZardForm(form: form, child: ...);
// 2. Let ZardForm create + dispose one from a schema
ZardForm(schema: _schema, defaultValues: const {...}, child: ...);
// 3. Builder form β get the controller in the callback
ZardForm.builder(
schema: _schema,
builder: (context, form) => ...,
);
ZardField<T>
Headless binder. Registers a field at name and exposes its controller to the subtree.
// Wrap a widget that knows how to read the field from context (e.g. ZardInput)
const ZardField<String>(
name: 'email',
child: ZardInput(label: 'Email'),
);
// Or use the builder to wire ANY widget yourself
ZardField<double>.builder(
name: 'volume',
defaultValue: 50,
builder: (ctx, field, state) => Slider(
value: state.value ?? 0,
min: 0,
max: 100,
onChanged: state.disabled ? null : (v) => field.setValue(v),
),
);
ZardField accepts name, defaultValue, and disabled.
ZardFieldController / ZardFieldState
Each field owns a controller exposing a ValueListenable<ZardFieldState<T>>. The state is an
immutable snapshot:
state.value // current value
state.errors // List<String>
state.error // first error or null
state.hasError // bool
state.isTouched // bool
state.isDirty // bool
state.isValidating // bool (async validation in progress)
state.disabled // bool
From the controller you can setValue, setTouched, setErrors, setDisabled, and (for
string fields) access a lazily-allocated textController / focusNode.
Validation β
When does it run? ValidationMode + RevalidateMode
mode controls when a field is validated for the first time; revalidateMode controls
when an already-errored field is re-checked as the user fixes it.
final form = ZardFormController(
schema: _schema,
mode: ValidationMode.onTouched, // see table below
revalidateMode: RevalidateMode.onChange,
);
ValidationMode |
Validates⦠|
|---|---|
onSubmit (default) |
only when the form is submitted |
onChange |
on every change (most aggressive) |
onBlur |
when a field loses focus |
onTouched |
after a field has been touched once |
all |
on every change and blur |
RevalidateMode |
After an error, re-validates⦠|
|---|---|
onChange (default) |
on every change |
onBlur |
on blur |
onSubmit |
only on the next submit |
Cross-field rules with .refine()
Refinements live on the schema. Their errors surface as form-level errors:
final _schema = z.map({
'password': z.string().min(6),
'confirm': z.string().min(6),
}).refine(
(data) => data['password'] == data['confirm'],
message: 'Passwords must match',
);
// Show them in the UI:
AnimatedBuilder(
animation: form,
builder: (ctx, _) {
if (form.formErrors.isEmpty) return const SizedBox.shrink();
return Text(form.formErrors.first,
style: const TextStyle(color: Color(0xFFB91C1C)));
},
);
Manual control: setError, clearErrors, trigger
form.setError('email', 'Email already exists'); // push a server error onto a field
form.clearErrors('email'); // clear one field
form.clearErrors(); // clear everything
final emailOk = await form.trigger('email'); // validate one path
final formOk = await form.trigger(); // validate the whole form
Async validation
Enable the async pipeline and register per-field validators (debounced). Return null when
valid, or an error message when not:
final form = useForm(
schema: z.map({'username': z.string().min(3)}),
mode: ValidationMode.onChange,
asyncValidation: true,
);
form.registerAsyncValidator(
'username',
(value, allValues) async {
if (value is! String || value.length < 3) return null;
await Future.delayed(const Duration(milliseconds: 600));
return _takenUsernames.contains(value) ? '"$value" is already taken' : null;
},
debounce: const Duration(milliseconds: 400),
);
ZardInput shows a spinner during async validation β customize it with loadingBuilder:
ZardInput(
label: 'Username',
loadingBuilder: (_) => const SizedBox(
width: 16, height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
Submitting π€
handleSubmit returns a VoidCallback ready for onPressed:. It validates, then calls
onValid (with the parsed/transformed values) or the optional onInvalid:
ZardButton(
loading: form.isSubmitting,
onPressed: form.handleSubmit(
(values) async {
await api.createUser(values);
},
onInvalid: (errors) {
debugPrint('Blocked by: $errors');
},
),
child: const Text('Create account'),
);
Prefer to await the flow yourself? Use form.submit(onValid, onInvalid:). Read
form.isSubmitting / form.submitCount for UI state.
Because the values handed to onValid are the schema's parsed output, you can map them
straight onto a typed model:
class User {
const User({required this.name, required this.email});
final String name;
final String email;
}
onPressed: form.handleSubmit((data) async {
final user = User(name: data['name'] as String, email: data['email'] as String);
// ...
});
Watching values β and avoiding unwanted rebuilds π
This is the part that surprises people, so it's worth understanding the model.
How reactivity works
ZardFormController is a ChangeNotifier. There are two levels of subscription:
- Per-field β each field has its own
ZardFieldController(aValueListenable<ZardFieldState>). Widgets likeZardInputandZardFieldlisten to their own field, so typing in one field rebuilds only that field's subtree. - Form-level β the controller itself calls
notifyListeners()on a range of events:setValue, submit start/end, validation start/end, field register/unregister, and error changes. Anything subscribed to the whole form rebuilds on any of these.
What subscribes to the whole form
These rebuild on every form-level notification (including each keystroke, since setValue
notifies):
useForm(...)β it subscribes the host widget (viauseListenable) soform.isSubmitting,form.isValid, etc. stay live.AnimatedBuilder(animation: form, ...)ZardFormScope.of(context, listen: true)ZardWatchAll(builder: ...)
Why this is usually fine
A useForm widget re-running build is not the same as rebuilding the whole tree. Keep
your field subtrees const:
const ZardField<String>(name: 'email', child: ZardInput(label: 'Email')),
When the parent rebuilds, Flutter sees the identical const widget and skips that subtree.
The inputs still reflect typing because they listen to their own field listenable β not the
parent's rebuild. So in practice a useForm screen made of const fields is cheap.
The toolbox to minimize rebuilds
Reach for these when a rebuild actually shows up on a profiler:
-
ZardWatch<T>(name:)β rebuild only when a single field's value changes:ZardWatch<String>( name: 'first', builder: (ctx, value) => Text('Hello, ${value ?? ''}'), ); -
useWatch<T>(name)β the hook form of the same single-field subscription. -
useFormState(listen:)β subscribe only to the form flags you care about:final s = useFormState(listen: (snap) => [snap.isValid, snap.isDirty]); // rebuilds only when isValid or isDirty flips -
Scope
AnimatedBuildertightly β wrap just the submit button, not the whole screen:AnimatedBuilder( animation: form, builder: (ctx, _) => ZardButton(loading: form.isSubmitting, ...), ); -
Use the controller without subscribing β with the
ZardFormController(no-hooks) style, read the controller directly and only wrap the reactive bits, so the screen itself never subscribes to the whole form.
Before / after
// β οΈ Everything in this screen re-runs build on each keystroke (still cheap if fields are const,
// but the surrounding non-const widgets all rebuild too).
class Screen extends HookWidget {
Widget build(context) {
final form = useForm(schema: _schema);
return ExpensiveLayout(form: form); // non-const β rebuilds every notify
}
}
// β
Only the button rebuilds on form changes; the heavy layout is built once.
class Screen extends StatefulWidget { ... }
class _ScreenState extends State<Screen> {
late final form = ZardFormController(schema: _schema);
Widget build(context) => ZardForm(
form: form,
child: const ExpensiveLayout( // const β built once
submitButton: _ReactiveSubmit(), // wraps AnimatedBuilder internally
),
);
}
π‘ Want to see it? The Watch & FormState example screen renders live rebuild counters next to
ZardWatch,ZardWatchAll, anduseFormStateso you can watch exactly which subscribers re-render as you type.
Default values & reset β»οΈ
final form = useForm(
schema: _schema,
defaultValues: const {'email': 'default@zard.dev', 'nickname': 'guest'},
);
form.reset(); // back to defaultValues
form.reset(values: const {'email': 'new@x.dev'}); // new baseline
form.reset(keepDirty: true); // keep dirty flags
form.reset(keepErrors: true); // keep current errors
form.reset(keepTouched: true); // keep touched flags
Nested paths π³
Use dot notation for nested objects β errors land on the right nested field:
final _schema = z.map({
'user': z.map({
'name': z.string().min(2),
'address': z.map({
'street': z.string().min(3),
'city': z.string().min(2),
}),
}),
});
const ZardField<String>(name: 'user.name', child: ZardInput(label: 'Name')),
const ZardField<String>(name: 'user.address.street', child: ZardInput(label: 'Street')),
const ZardField<String>(name: 'user.address.city', child: ZardInput(label: 'City')),
Field arrays π
Manage dynamic lists with stable row IDs (use the id as the widget key so state survives
reorders):
final form = useForm(
schema: z.map({'skills': z.list(z.string().min(1))}),
defaultValues: const {'skills': ['Dart', 'Flutter']},
mode: ValidationMode.onChange,
);
final skills = useFieldArray<String>('skills', form: form);
// Render rows β bind each with `skills.$i`
for (var i = 0; i < skills.rows.value.length; i++)
Row(
key: ValueKey(skills.rows.value[i].id),
children: [
Expanded(
child: ZardField<String>(
name: 'skills.$i',
child: ZardInput(label: 'Skill #${i + 1}'),
),
),
IconButton(
icon: const Icon(Icons.arrow_upward),
onPressed: i == 0 ? null : () => skills.move(i, i - 1),
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => skills.remove(i),
),
],
),
OutlinedButton.icon(
onPressed: () => skills.append(''),
icon: const Icon(Icons.add),
label: const Text('Add skill'),
),
Available operations: append, prepend, insert, remove, swap, move, replace,
update. Without hooks, call form.useFieldArray<E>('skills') directly.
Conditional fields π
Use ZardWatch to show/hide fields based on another field's value (pair with .optional() in
the schema):
final _schema = z.map({
'subscribe': z.bool(),
'email': z.string().optional(),
});
const ZardField<bool>(
name: 'subscribe',
defaultValue: false,
child: ZardCheckbox(label: 'Subscribe to newsletter'),
),
ZardWatch<bool>(
name: 'subscribe',
builder: (ctx, on) => on == true
? const ZardField<String>(name: 'email', child: ZardInput(label: 'Email'))
: const SizedBox.shrink(),
),
Multi-step wizards π§
Gate each step with trigger(path) β validate just the current step before advancing:
final step = useState(0);
final form = useForm(schema: _schema, mode: ValidationMode.onTouched);
Future<void> next() async {
const pathByStep = ['name', 'email', 'plan'];
final ok = await form.trigger(pathByStep[step.value]);
if (!ok) return;
if (step.value == 2) {
await form.submit((data) async { /* finish */ });
} else {
step.value++;
}
}
ZardButton(
loading: form.isValidating || form.isSubmitting,
onPressed: next,
child: Text(step.value == 2 ? 'Submit' : 'Next'),
);
Material widgets π¨
Ready-made widgets that resolve their field from the surrounding ZardField (or take an
explicit name:).
| Widget | For | Highlights |
|---|---|---|
ZardInput |
String |
label, placeholder, helperText, obscureText, prefix/suffix, loadingBuilder, errorBuilder |
ZardTextarea |
String |
multi-line variant (minLines: 3, maxLines: 8) |
ZardCheckbox |
bool |
optional label, tristate |
ZardSwitch |
bool |
optional label |
ZardSelect<T> |
T |
options: [ZardSelectOption(value:, label:)] |
ZardRadioGroup<T> |
T |
options: [ZardRadioOption(value:, label:)], vertical/horizontal |
ZardButton |
β | loading, fullWidth, icon; pair with handleSubmit |
Kitchen-sink example:
const ZardField<String>(name: 'name', child: ZardInput(label: 'Name')),
ZardField<String>(
name: 'role',
child: ZardSelect<String>(
label: 'Role',
options: const [
ZardSelectOption(value: 'engineer', label: 'Engineer'),
ZardSelectOption(value: 'designer', label: 'Designer'),
],
),
),
const ZardField<bool>(
name: 'newsletter',
defaultValue: false,
child: ZardSwitch(label: 'Subscribe to newsletter'),
),
ZardField<String>(
name: 'tier',
defaultValue: 'free',
child: ZardRadioGroup<String>(
options: const [
ZardRadioOption(value: 'free', label: 'Free'),
ZardRadioOption(value: 'pro', label: 'Pro'),
],
),
),
Headless / Radix-style composition π§©
Compose a field from small headless pieces. They pick up the surrounding ZardField context β
no need to repeat the name:
ZardField<String>(
name: 'email',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
ZardLabel('Email', requiredMarker: Text(' *', style: TextStyle(color: Color(0xFFB91C1C)))),
SizedBox(height: 4),
ZardInput(placeholder: 'you@example.com'),
ZardDescription('We never share your email.'),
ZardErrorMessage(),
],
),
);
Pieces: ZardLabel, ZardDescription, ZardErrorMessage (auto-bound, or pass name: /
builder:), and ZardFormSection for grouping with an optional title/description.
Hooks reference πͺ
All hooks live in package:zard_flutter/hooks.dart (require flutter_hooks).
| Hook | Returns | Purpose |
|---|---|---|
useForm({schema, defaultValues, mode, ...}) |
ZardFormController |
Create + own a form, subscribed to form-level state |
useController<T>(name, {form, defaultValue, disabled}) |
ZardFieldController<T> |
Register/resolve a field, subscribed to its state |
useFieldState<T>(name, {form}) |
ZardFieldState<T> |
Just the field's current state |
useWatch<T>(name, {form, defaultValue}) |
T? |
Reactive read of a single field value |
useFieldArray<E>(name, {form}) |
ZardFieldArray<E> |
Dynamic list management |
useFormState({form, listen}) |
ZardFormSnapshot |
Form-level flags; listen: limits rebuilds |
useZardFormContext() |
ZardFormController |
Resolve the nearest form from context |
DevTools π
Drop a live inspection panel anywhere inside a form β values, errors, dirty/touched/disabled flags, and a JSON dump:
ZardDevtools(form: form, collapsed: true);
API reference π
Core
| Name | Description |
|---|---|
ZardFormController |
The reactive form (ChangeNotifier) backed by a ZMap schema |
ZardFieldController<T> |
Per-field state, value, errors, optional text controller/focus node |
ZardFieldState<T> |
Immutable field snapshot (value, errors, isTouched, β¦) |
ZardFieldArray<E> / ZardFieldArrayRow<E> |
Dynamic list + stable-ID rows |
ValidationMode / RevalidateMode |
Enums controlling validation timing |
AsyncFieldValidator |
Future<String?> Function(value, allValues) typedef |
ZardFormScope |
InheritedNotifier exposing the controller (of / maybeOf) |
| path utils | parsePath, canonicalizePath, readPath, writePath, removePath |
Headless widgets
| Name | Description |
|---|---|
ZardForm / ZardForm.builder |
Own or wrap a controller; provide it to descendants |
ZardField<T> / ZardField<T>.builder |
Bind a field at name; .builder wraps any widget |
ZardFieldBinding |
Inherited access to the field controller (of / maybeOf) |
ZardWatch<T> |
Rebuild on a single field's value change |
ZardWatchAll |
Rebuild on any form-level change |
ZardLabel |
Headless label (forwards taps to the field's focus node) |
ZardErrorMessage |
Shows field errors (context-bound or by name:) |
ZardDescription |
Helper/description text |
ZardFormSection |
Grouping with optional title/description |
Material widgets
| Name | Description |
|---|---|
ZardInput |
Material TextField bound to a String field |
ZardTextarea |
Multi-line ZardInput |
ZardCheckbox / ZardSwitch |
bool fields |
ZardSelect<T> / ZardSelectOption<T> |
Dropdown |
ZardRadioGroup<T> / ZardRadioOption<T> |
Radio group |
ZardButton |
Elevated button with loading state |
ZardDevtools |
Live form-state inspector |
Hooks
See the Hooks reference table above.
Running the example π§ͺ
The example app is a showcase of 15 screens covering every feature in this README β basic login, registration with cross-field refine, validation modes, async validation, field arrays, nested paths, defaults/reset, manual errors, watch/form-state, transforms, Radix-style composition, multi-step wizard, conditional fields, custom widgets, and the DevTools showcase.
cd example
flutter run
License π
MIT.
Support π
If Zard Flutter saves you time, consider supporting development π Buy Me a Coffee π. Thank you!
Libraries
- hooks
- Optional
flutter_hooksintegration. - zard_flutter
- Reactive forms for Flutter, powered by the zard validation library.