Skip to Content
DocsAckHow-To GuidesFlutter Form Validation

Form Validation in Flutter with Ack

This guide shows how to use Ack for validating forms in Flutter applications.

Prerequisites

  • Flutter basics: Creating widgets, managing state, and using forms
  • Ack fundamentals: Creating schemas and validation (see Quickstart Tutorial)
  • Validation rules: Built-in constraints (see Validation Rules)

Install Ack in your Flutter project:

flutter pub add ack

Basic form validation with TextFormField

Ack works with Flutter form APIs by running safeParse inside TextFormField.validator and returning a String? error message (null when valid).

import 'package:ack/ack.dart'; import 'package:flutter/material.dart'; class SignUpForm extends StatefulWidget { const SignUpForm({super.key}); @override State<SignUpForm> createState() => _SignUpFormState(); } class _SignUpFormState extends State<SignUpForm> { // GlobalKey to manage Form state final _formKey = GlobalKey<FormState>(); // Controllers for input fields final _usernameController = TextEditingController(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); // Define Ack schemas for validation rules // See: [Schema Types](/documentation/ack/essentials/schemas), [Validation Rules](/documentation/ack/essentials/validation) final _usernameSchema = Ack.string() .minLength(3) .maxLength(20) .matches(r'^[a-zA-Z0-9_]+$') .notEmpty(); final _emailSchema = Ack.string() .email() .notEmpty(); final _passwordSchema = Ack.string() .minLength(8) .matches(r'.*[A-Z].*') .matches(r'.*[a-z].*') .matches(r'.*[0-9].*') .notEmpty(); @override Widget build(BuildContext context) { return Form( key: _formKey, // Associate the key with the Form child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Username Field TextFormField( controller: _usernameController, decoration: const InputDecoration(labelText: 'Username'), // Use the schema's validate method in the validator validator: (value) { final result = _usernameSchema.safeParse(value); // Return the error message if validation fails // See: [Error Handling](/documentation/ack/essentials/error-handling) return result.isFail ? result.getError().toString() : null; }, autovalidateMode: AutovalidateMode.onUserInteraction, ), const SizedBox(height: 16), // Email Field TextFormField( controller: _emailController, decoration: const InputDecoration(labelText: 'Email'), keyboardType: TextInputType.emailAddress, validator: (value) { final result = _emailSchema.safeParse(value); return result.isFail ? result.getError().toString() : null; }, autovalidateMode: AutovalidateMode.onUserInteraction, ), const SizedBox(height: 16), // Password Field TextFormField( controller: _passwordController, decoration: const InputDecoration(labelText: 'Password'), obscureText: true, validator: (value) { final result = _passwordSchema.safeParse(value); return result.isFail ? result.getError().toString() : null; }, autovalidateMode: AutovalidateMode.onUserInteraction, ), const SizedBox(height: 24), // Submit Button ElevatedButton( onPressed: _submitForm, child: const Text('Sign Up'), ), ], ), ); } void _submitForm() { // Validate the entire form using the GlobalKey if (_formKey.currentState!.validate()) { // If the form is valid, display a Snackbar or proceed. ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Processing Data')), ); print('Form is valid!'); print('Username: ${_usernameController.text}'); print('Email: ${_emailController.text}'); // Usually, you would send this data to a server } else { print('Form is invalid.'); } } @override void dispose() { // Dispose controllers when the widget is removed from the widget tree _usernameController.dispose(); _emailController.dispose(); _passwordController.dispose(); super.dispose(); } }

Key steps: define an AckSchema per field; in TextFormField.validator, call schema.safeParse(value) and return result.getError().toString() on failure; trigger full-form validation via _formKey.currentState!.validate(); set autovalidateMode: AutovalidateMode.onUserInteraction for real-time feedback. Pass message: inside the schema definition for custom error messages.

Real-time validation with TextField

Without a Form widget, listen to controller changes and update the error state directly.

import 'package:ack/ack.dart'; import 'package:flutter/material.dart'; class RealtimeValidationField extends StatefulWidget { const RealtimeValidationField({super.key}); @override State<RealtimeValidationField> createState() => _RealtimeValidationFieldState(); } class _RealtimeValidationFieldState extends State<RealtimeValidationField> { final _emailController = TextEditingController(); String? _emailErrorText; // State variable to hold the error message // Define the schema final _emailSchema = Ack.string() .email() .notEmpty(); @override void initState() { super.initState(); // Add listener to validate on change _emailController.addListener(_validateEmail); } void _validateEmail() { final text = _emailController.text; // Only validate if the field is not empty (or on first interaction) // Adjust logic based on desired UX (e.g., validate after first blur) if (text.isNotEmpty) { final result = _emailSchema.safeParse(text); // Update the error state variable, triggering a rebuild setState(() { _emailErrorText = result.isFail ? result.getError().toString() : null; }); } else { // Clear error if field becomes empty setState(() { _emailErrorText = null; }); } } @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(16.0), child: TextField( controller: _emailController, decoration: InputDecoration( labelText: 'Email', // Display the error text from the state variable errorText: _emailErrorText, ), keyboardType: TextInputType.emailAddress, ), ); } @override void dispose() { _emailController.removeListener(_validateEmail); _emailController.dispose(); super.dispose(); } }

Key steps: hold a state variable for the error message; add a listener to TextEditingController in initState; call schema.safeParse() inside the listener and setState with the result; remove the listener in dispose.

Validating entire forms on submission

Instead of validating field by field, validate the complete data structure on submission using Ack.object.

// Define a schema for the whole form data final _formSchema = Ack.object({ 'username': _usernameSchema, // Reuse field schemas 'email': _emailSchema, 'password': _passwordSchema, 'confirmPassword': Ack.string() .notEmpty(), }).refine( (data) => data['password'] == data['confirmPassword'], message: 'Passwords do not match', ); void _submitForm() { // Gather all form data into a map final formData = { 'username': _usernameController.text, 'email': _emailController.text, 'password': _passwordController.text, 'confirmPassword': _confirmPasswordController.text, // Assume this controller exists }; // Validate the entire map final result = _formSchema.safeParse(formData); if (result.isOk) { print('Entire form data is valid!'); // Submit formData } else { final error = result.getError(); print('Form submission error: $error'); // You might need to map the error path back to specific fields // to display errors if not using TextFormField validators. } }

Key steps: build an Ack.object schema that reuses your field schemas; add .refine() for cross-field rules; call safeParse on the full form map at submission. On failure, use error.path to map errors back to specific fields when not using TextFormField’s built-in display.

Last updated on