form_wizard π§ββοΈ
The most performant, customizable, lightweight, and powerful form builder for Flutter.
β¨ Demo
π§ Clean Form UI

π©οΈ Real-time Validation

πͺ Custom Widget Validation

π What's New in v0.1.2
This release is a pure refinement pass focused on making form_wizard faster, cleaner, and more reliable under real typing load.
| Upgrade | Why it matters |
|---|---|
| Single-emission field updates | A field edit now updates the value and validation error together, avoiding extra provider notifications per keystroke |
| Hidden-field-aware validity | Conditional fields that are hidden no longer block submit or global validity |
| No-op configuration guard | Repeated setup/configuration calls avoid unnecessary state emissions |
| Cleaner Stepper internals | Step validity watches only the active step's relevant fields instead of broad derived maps |
| Safer OTP cooldown | OTP resend timers/notifiers are now owned and disposed correctly |
| Modern dropdown API | Uses initialValue instead of deprecated dropdown value |
The core promise is now stronger: only the field being edited should react to that edit, and even that edit is handled with the smallest practical state update.
π Features
Core
- Smart validation with reactive
FormWizardController - Fine-grained state updates using
formStateProvider.select(...) - Single-emission value + validation updates per field edit
ValueListenableaccess toisFormValid,formValues, and field errors
Fields
- Builtβin: text, email, password, number, dropdown, date
- Custom widgets via
customBuilder - Fully customizable with
decorationBuilder
Advanced
- Conditional visibility for dependent fields
- Dynamic field arrays for repeatable groups
- Multiβstep forms with
FormWizardStepper
Templates (new)
- LoginForm, SignupForm, OTPVerificationForm, AddressForm, PaymentForm
- Field presets for common use cases
β‘ Performance
Only the field being edited rebuilds. The rest of the form stays static.
form_wizard uses Riverpod 3 internally to avoid whole-form rebuilds. You do not
need to install, import, or initialize Riverpod in your app to use the package.
The package stores values and errors in a single immutable FormState, exposed through formStateProvider. Each rendered field listens only to its own slice of state:
ref.watch(formStateProvider.select((state) => state.values[fieldName]));
ref.watch(formStateProvider.select((state) => state.errors[fieldName]));
When a user edits email, only the email field receives the changed selected value. Sibling fields keep their existing widget subtrees, which makes large forms much cheaper to type into.
In v0.1.2, the edited value and that field's validation error are committed in a single immutable state update. That means fewer provider notifications, fewer derived-listener checks, and less work per keystroke.
Conditional fields also declare their dependencies, so a state field can react to country without every other field waking up.
Global UI, such as submit buttons, can listen to the controller's isFormValid
ValueListenable, so it rebuilds only when validity changes. Advanced users can
also import the exported providers directly, but the normal API does not require
Riverpod knowledge.
FormWizard and FormWizardStepper are also safe inside TabBarView/page-style layouts; their internal provider scopes are lifecycle-hardened for tab switching.
What does not rebuild?
- Other fields when one field changes
- Inactive stepper steps
- Hidden conditional fields
- Submit controls unless derived validity changes
- Field-array siblings unless their item list or their own field state changes
π§ͺ Usage
Hereβs the smallest useful FormWizard setup:
πͺ Step-by-Step
- Initialize a
FormWizardController. - Define fields with presets like
FormWizard.emailField()or withFormWizardFieldModel. - Pass the fields and controller to
FormWizard. - Handle submission with
onSubmit.
import 'package:form_wizard/form_wizard.dart';
final formController = FormWizardController();
FormWizard(
controller: formController,
fields: [
FormWizard.emailField(),
FormWizard.passwordField(),
],
onSubmit: (values) {
print(values);
},
)
π― Conditional Visibility
Show or hide any field based on other field values:
FormWizardFieldModel(
name: 'company_name',
label: 'Company Name',
type: FieldType.text,
validators: [Validators.required()],
visibleWhenDependsOn: ['account_type'],
visibleWhen: (values) => values['account_type'] == 'Business',
)
Why visibleWhenDependsOn matters: it keeps visibility reactive without making the entire form rebuild. In the example above, company_name only reevaluates when account_type changes.
Hidden fields keep their values but do not block validation. If a required field is hidden, submit can still pass.
π Dynamic Field Arrays
Use field arrays when users need to add as many groups as they want: phone numbers, addresses, team members, dependents, invoices, emergency contacts, anything repeatable.
FormWizard(
controller: formController,
fields: const [],
fieldArrays: [
FormWizardFieldArrayModel(
name: 'team_members',
label: 'Team Members',
initialItemCount: 1,
minItems: 1,
maxItems: 10,
fieldBuilder: (item) => [
FormWizardFieldModel(
name: item.fieldName('name'),
label: 'Member ${item.index + 1} Name',
type: FieldType.text,
validators: [Validators.required()],
),
FormWizardFieldModel(
name: item.fieldName('email'),
label: 'Member ${item.index + 1} Email',
type: FieldType.email,
validators: [Validators.email()],
),
],
),
],
)
Each array item receives a stable internal ID, so values stay attached to the right item when users reorder rows. The generated names look like:
team_members.fw_0.name
team_members.fw_0.email
team_members.fw_1.name
You can also control arrays imperatively:
formController.addFieldArrayItem('team_members');
formController.removeFieldArrayItem('team_members', itemId);
formController.reorderFieldArrayItem('team_members', oldIndex, newIndex);
On submit, you can read either the full flat value map or grouped array values:
final members = formController.getFieldArrayValues('team_members');
// [
// {'name': 'Ava', 'email': 'ava@example.com'},
// {'name': 'Noah', 'email': 'noah@example.com'},
// ]
The default UI includes add, remove, move up, and move down controls. You can replace them with addButtonBuilder, removeButtonBuilder, moveUpButtonBuilder, and moveDownButtonBuilder.
π§ Multi-Step Forms (FormWizardStepper)
Create complex, multi-step forms with zero performance penalty. Each step is isolated β typing in Step 2 does NOT rebuild Step 1.
Features
- Step isolation: only the active step is in the widget tree
- Per-step validation: the next button enables only when the current step is valid
- Data persistence: all step data is preserved in global form state
- Custom UI: full control via
stepBuilder - Performance: no rebuilds from inactive steps
Basic Usage
FormWizardStepper(
steps: [
FormWizardStep(
title: 'Personal',
fields: [
FormWizard.nameField(name: 'name'),
FormWizard.emailField(name: 'email'),
],
),
FormWizardStep(
title: 'Address',
fields: [
FormWizard.streetField(name: 'street'),
FormWizard.cityField(name: 'city'),
],
),
],
onFinish: (values) => print('Form completed: $values'),
)
Custom UI with stepBuilder
Take full control of the stepper layout:
FormWizardStepper(
steps: [...],
stepBuilder: (context, stepper) {
return Column(
children: [
// Custom progress indicator
LinearProgressIndicator(
value: (stepper.currentStep + 1) / stepper.steps.length,
),
// Step title
Text(stepper.steps[stepper.currentStep].title),
const SizedBox(height: 16),
// Form fields
stepper.fields,
const SizedBox(height: 24),
// Custom navigation buttons
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (stepper.currentStep > 0)
ElevatedButton(
onPressed: stepper.onBack,
child: Text('Back'),
),
ElevatedButton(
onPressed: stepper.onNext,
child: Text(
stepper.currentStep == stepper.steps.length - 1
? 'Finish'
: 'Next',
),
),
],
),
],
);
},
)
Performance Note
Only the active step's fields are in the widget tree. When you navigate away from a step, its widgets are disposed, saving memory and preventing unnecessary rebuilds.
π¦ Built-in Form Templates
Stop rewriting the same forms. Use pre-built, fully customizable templates that are powered by the same optimized FormWizard internals.
LoginForm
LoginForm(
identityType: FormWizardIdentityType.email, // or .phone, .username
onLogin: (identity, password) async {
await authService.login(identity, password);
},
forgotPasswordLink: () => navigateToForgotPassword(),
rememberMe: true,
submitLabel: 'Sign In',
)
SignupForm
SignupForm(
identityType: FormWizardIdentityType.email,
requireTermsAcceptance: true,
onSignup: (name, identity, password) async {
await authService.register(name, identity, password);
},
submitLabel: 'Create Account',
)
OTPVerificationForm
OTPVerificationForm(
otpLength: 6,
resendCooldownSeconds: 30,
onVerify: (otp) async {
await authService.verifyOTP(otp);
},
onResend: () async {
await authService.resendOTP();
},
)
AddressForm
AddressForm(
onSubmit: (address) async {
await saveAddress(address);
},
includeState: true,
submitLabel: 'Save Address',
)
PaymentForm
PaymentForm(
onSubmit: (paymentDetails) async {
await processPayment(paymentDetails);
},
submitLabel: 'Pay Now',
)
π§© Field Presets (Composable)
Don't want a full template? Use individual field presets to build your own forms:
FormWizard(
controller: formController,
fields: [
FormWizard.nameField(),
FormWizard.emailField(),
FormWizard.passwordField(),
FormWizard.phoneField(),
FormWizard.streetField(),
FormWizard.cityField(),
FormWizard.zipField(),
FormWizard.countryDropdown(),
FormWizard.otpField(),
],
onSubmit: (values) => print(values),
)
π― Complete Example: Multi-step Checkout
FormWizardStepper(
steps: [
FormWizardStep(
title: 'Shipping',
fields: [
FormWizard.streetField(),
FormWizard.cityField(),
FormWizard.zipField(),
FormWizard.countryDropdown(),
],
),
FormWizardStep(
title: 'Payment',
fields: [
FormWizardFieldModel(
name: 'card_number',
label: 'Card Number',
type: FieldType.number,
validators: [Validators.required()],
),
FormWizardFieldModel(
name: 'expiry',
label: 'Expiry MM/YY',
type: FieldType.text,
validators: [Validators.required()],
),
],
),
],
onFinish: (values) => placeOrder(values),
)
π§± Supported Field Types
| Field Type | Description |
|---|---|
text |
Standard text field |
email |
Validates email format |
password |
Obscured password input |
number |
Numeric input |
date |
Date picker with formatting |
dropdown |
Dropdown menu from a list of options |
custom |
Pass your own widget via customBuilder |
OTP is available as a preset with FormWizard.otpField(length: 4) or FormWizard.otpField(length: 6).
π¨ Custom Decoration
Use decorationBuilder to pass your own InputDecoration:
FormWizardFieldModel(
name: 'username',
label: 'Username',
type: FieldType.text,
validators: [
Validators.required(),
Validators.regex(
RegExp(r'^[a-zA-Z0-9_]+$'),
message: 'Only letters, numbers and underscores allowed',
),
],
decorationBuilder:
(errorText, controller) => InputDecoration(
prefixIcon: const Icon(Icons.person),
suffixIcon: Builder(
builder: (context) {
if (controller.text.isEmpty) {
return SizedBox();
}
return IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
controller.clear();
},
);
},
),
labelText: 'Username',
hintText: 'e.g. tanay_dev_99',
helperText: 'Only letters, numbers, and underscore allowed',
errorText: errorText,
errorStyle: TextStyle(color: Colors.red[800]),
labelStyle: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.indigo,
),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
focusedBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.indigo, width: 2),
borderRadius: BorderRadius.circular(12),
),
),
),
π οΈ Validators
Use the built-in Validators class:
validators: [ Validators.required(), Validators.email(), Validators.minLength(6), Validators.maxLength(6), Validators.number(), Validators.regex() ]
Common additions include:
validators: [
Validators.phone(),
Validators.exactLength(6),
]
Or define your own:
validators: [ (value) => value == 'magic' ? null : 'Only "magic" is accepted!', ]
π‘ Custom Field Widget
FormWizardFieldModel(
name: 'custom_slider',
label: 'Custom Field',
type: FieldType.custom,
customBuilder: (controller, errorText, onChanged) {
double value = double.tryParse(controller.text) ?? 0.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("Select value"),
Slider(
min: 0,
max: 100,
divisions: 100,
value: value,
onChanged: (val) {
controller.text = val.toString();
onChanged(val.toString());
},
),
if (errorText != null)
Padding(
padding: const EdgeInsets.only(left: 8.0, top: 4),
child: Text(
errorText,
style: const TextStyle(color: Colors.red),
),
),
],
);
},
validators: [
(val) {
final v = double.tryParse(val ?? '');
if (v == null || v < 20) return "Value must be at least 20";
return null;
},
],
),
π API Reference
π form_wizard API Docs on pub.dev
π€ Contributing
We welcome contributions from the community to make FormWizard even better, smarter, and more flexible!
Whether it's fixing bugs, adding new features, improving documentation, or just sharing ideas β every contribution counts.
π How to Contribute
- Fork the repository.
- Create a new branch:
git checkout -b your-feature-name - Make your changes and commit them with clear messages.
- Push to your fork:
git push origin your-feature-name - Open a Pull Request on the
mainbranch.
π§ Contribution Ideas
- Add more custom field types (e.g., phone number, image picker, multi-select).
- Improve accessibility (a11y) support.
- Create more built-in decorators or validators.
- Help write or translate documentation.
- Suggest enhancements or refactor logic.
π Guidelines
- Follow Flutter/Dart formatting:
dart format . - Keep your PR focused and well-scoped.
- Update tests and examples if needed.
- Be kind and respectful in code reviews.
Thanks for helping make FormWizard the smartest form solution for Flutter! β€οΈ
π License
This project is licensed under the MIT License - see the LICENSE file for details.
Crafted with β€οΈ by Tanay & powered by Flutter + RiverPod.