Skip to content

Form State ​

Reacton provides a reactive form system built on top of its core primitives. Forms are composed of FieldReacton instances managed by a FormReacton, with built-in validation, dirty tracking, touch state, and async validation support.

Overview ​

The form system consists of three parts:

  1. FieldReacton<T> -- A writable reacton that holds a FieldState<T> with value, error, dirty, and touched tracking
  2. FormReacton -- Groups multiple fields together and tracks form-level submission state
  3. Validators -- Composable validation functions that return error messages or null

FieldReacton ​

A FieldReacton<T> is a WritableReacton<FieldState<T>> that wraps a form field's value along with its validation and interaction state.

Creating Fields ​

dart
final emailField = reactonField<String>(
  '',
  validators: [required(), email()],
  name: 'email',
);

final passwordField = reactonField<String>(
  '',
  validators: [required(), minLength(8)],
  name: 'password',
);

final ageField = reactonField<int>(
  0,
  name: 'age',
);

Signature ​

dart
FieldReacton<T> reactonField<T>(
  T initialValue, {
  List<Validator<T>> validators = const [],
  Future<String?> Function(T value)? asyncValidator,
  String? name,
})
ParameterTypeDescription
initialValueTThe starting value for the field
validatorsList<Validator<T>>Synchronous validators (run in order, first error wins)
asyncValidatorFuture<String?> Function(T)?Optional async validator (e.g., server-side uniqueness check)
nameString?Debug name

FieldState ​

Every FieldReacton<T> holds a FieldState<T>:

dart
class FieldState<T> {
  final T value;          // Current field value
  final String? error;    // Validation error message (null if valid)
  final bool isDirty;     // Has the value changed from initial?
  final bool isTouched;   // Has the user interacted with this field?
  final bool isValidating; // Is async validation in progress?

  bool get isValid => error == null;
}
PropertyTypeDescription
valueTThe current field value
errorString?Current validation error, or null if valid
isDirtybooltrue if the value differs from the initial value
isTouchedbooltrue if the user has interacted with this field
isValidatingbooltrue if async validation is running
isValidboolConvenience getter: error == null

Store Extensions for Fields ​

The ReactonStoreField extension provides convenience methods for working with fields:

MethodSignatureDescription
setFieldValuevoid setFieldValue<T>(FieldReacton<T> field, T value)Set value with automatic validation and dirty tracking
touchFieldvoid touchField<T>(FieldReacton<T> field)Mark field as touched
resetFieldvoid resetField<T>(FieldReacton<T> field)Reset to initial state
fieldValueT fieldValue<T>(FieldReacton<T> field)Get current value (convenience)
fieldErrorString? fieldError<T>(FieldReacton<T> field)Get current error (convenience)
dart
// Set a field value with automatic validation
store.setFieldValue(emailField, 'user@example.com');

// Mark as touched (show errors after blur)
store.touchField(emailField);

// Read convenience getters
final email = store.fieldValue(emailField);
final error = store.fieldError(emailField);

FormReacton ​

A FormReacton groups multiple FieldReacton instances together and tracks form-level submission state.

Creating Forms ​

dart
final loginForm = reactonForm(
  fields: {
    'email': emailField,
    'password': passwordField,
  },
  name: 'loginForm',
);

Signature ​

dart
FormReacton reactonForm({
  required Map<String, FieldReacton> fields,
  String? name,
})
ParameterTypeDescription
fieldsMap<String, FieldReacton>Named map of field reactons
nameString?Debug name

FormState ​

The FormReacton holds a FormState (from the form_reacton.dart module, not Flutter's FormState):

dart
class FormState {
  final bool isSubmitting;   // Is the form currently being submitted?
  final bool isSubmitted;    // Has the form been successfully submitted?
  final String? submitError; // Error from the last submission attempt
  final int submitCount;     // Number of successful submissions
}

Store Extensions for Forms ​

The ReactonStoreForm extension provides form-level operations:

MethodSignatureDescription
isFormValidbool isFormValid(FormReacton form)Check if all fields are valid
validateFormbool validateForm(FormReacton form)Validate all fields, returns true if all valid
submitFormFuture<void> submitForm(FormReacton form, {...})Validate and submit with callback
resetFormvoid resetForm(FormReacton form)Reset all fields and form state
touchAllFieldsvoid touchAllFields(FormReacton form)Mark all fields as touched
isFormDirtybool isFormDirty(FormReacton form)Check if any field has been modified
formFieldFieldReacton<T> formField<T>(FormReacton form, String name)Get a field by name

Submitting Forms ​

dart
await store.submitForm(
  loginForm,
  onValid: (values) async {
    // values is Map<String, dynamic> of field name -> value
    await api.login(
      email: values['email'] as String,
      password: values['password'] as String,
    );
  },
  onError: (error) {
    print('Submission failed: $error');
  },
);

The submitForm method:

  1. Validates all fields (calls validateForm)
  2. If invalid, calls onError with a message and returns
  3. Sets isSubmitting = true on the form state
  4. Calls onValid with a Map<String, dynamic> of field names to values
  5. On success, sets isSubmitted = true and increments submitCount
  6. On exception, sets submitError with the error message

Built-in Validators ​

Reacton ships with composable validators for common use cases. Each validator is a function that returns a Validator<T> (a String? Function(T) typedef).

ValidatorSignatureDescription
required()Validator<String> required({String message})Fails if the string is empty
minLength(n)Validator<String> minLength(int min, {String? message})Fails if length < min
maxLength(n)Validator<String> maxLength(int max, {String? message})Fails if length > max
email()Validator<String> email({String message})Fails if not a valid email format
pattern(regex)Validator<String> pattern(RegExp regex, {String message})Fails if the string does not match the regex
range(min, max)Validator<num> range(num min, num max, {String? message})Fails if value is outside the range
matches(getter)Validator<String> matches(String Function() getOtherValue, {String message})Fails if value does not equal the other value (for confirmation fields)
compose(validators)Validator<T> compose<T>(List<Validator<T>> validators)Runs validators in order, returns first error

Custom Error Messages ​

Every built-in validator accepts a custom message parameter:

dart
final emailField = reactonField<String>(
  '',
  validators: [
    required(message: 'Email is required'),
    email(message: 'Please enter a valid email'),
  ],
);

Custom Validators ​

A validator is simply a String? Function(T). Return null for valid, or an error message string:

dart
Validator<String> noSpaces({String message = 'Must not contain spaces'}) {
  return (value) => value.contains(' ') ? message : null;
}

// Use it
final usernameField = reactonField<String>(
  '',
  validators: [required(), noSpaces(), minLength(3)],
);

Composing Validators ​

Use compose() to combine multiple validators into one:

dart
final passwordValidator = compose<String>([
  required(),
  minLength(8),
  pattern(RegExp(r'[A-Z]'), message: 'Must contain an uppercase letter'),
  pattern(RegExp(r'[0-9]'), message: 'Must contain a number'),
]);

final passwordField = reactonField<String>(
  '',
  validators: [passwordValidator],
);

Complete Login Form Example ​

Here is a full login form demonstrating fields, validation, submission, and UI integration:

dart
import 'package:flutter/material.dart';
import 'package:flutter_reacton/flutter_reacton.dart';

// 1. Define fields
final emailField = reactonField<String>(
  '',
  validators: [required(), email()],
  name: 'email',
);

final passwordField = reactonField<String>(
  '',
  validators: [required(), minLength(8)],
  name: 'password',
);

// 2. Define form
final loginForm = reactonForm(
  fields: {
    'email': emailField,
    'password': passwordField,
  },
  name: 'loginForm',
);

// 3. Build the UI
class LoginPage extends StatelessWidget {
  const LoginPage({super.key});

  @override
  Widget build(BuildContext context) {
    final emailState = context.watch(emailField);
    final passwordState = context.watch(passwordField);
    final formState = context.watch(loginForm);

    return Scaffold(
      appBar: AppBar(title: const Text('Login')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            TextField(
              decoration: InputDecoration(
                labelText: 'Email',
                errorText: emailState.isTouched ? emailState.error : null,
              ),
              onChanged: (value) {
                context.reactonStore.setFieldValue(emailField, value);
              },
              onTap: () {
                context.reactonStore.touchField(emailField);
              },
            ),
            const SizedBox(height: 16),
            TextField(
              obscureText: true,
              decoration: InputDecoration(
                labelText: 'Password',
                errorText: passwordState.isTouched ? passwordState.error : null,
              ),
              onChanged: (value) {
                context.reactonStore.setFieldValue(passwordField, value);
              },
              onTap: () {
                context.reactonStore.touchField(passwordField);
              },
            ),
            const SizedBox(height: 24),
            if (formState.submitError != null)
              Text(
                formState.submitError!,
                style: const TextStyle(color: Colors.red),
              ),
            const SizedBox(height: 8),
            ElevatedButton(
              onPressed: formState.isSubmitting
                  ? null
                  : () async {
                      final store = context.reactonStore;
                      store.touchAllFields(loginForm);
                      await store.submitForm(
                        loginForm,
                        onValid: (values) async {
                          // Call your API
                          await Future.delayed(
                            const Duration(seconds: 1),
                          );
                          print('Logged in with: $values');
                        },
                        onError: (error) {
                          print('Login failed: $error');
                        },
                      );
                    },
              child: formState.isSubmitting
                  ? const CircularProgressIndicator()
                  : const Text('Login'),
            ),
          ],
        ),
      ),
    );
  }
}

Password Confirmation with matches ​

Use the matches() validator for confirmation fields:

dart
final passwordField = reactonField<String>(
  '',
  validators: [required(), minLength(8)],
  name: 'password',
);

final confirmPasswordField = reactonField<String>(
  '',
  validators: [
    required(),
    matches(
      () => store.fieldValue(passwordField),
      message: 'Passwords do not match',
    ),
  ],
  name: 'confirmPassword',
);

Async Validation ​

For server-side validation (e.g., checking if a username is taken):

dart
final usernameField = reactonField<String>(
  '',
  validators: [required(), minLength(3)],
  asyncValidator: (value) async {
    final isTaken = await api.checkUsername(value);
    return isTaken ? 'Username is already taken' : null;
  },
  name: 'username',
);

When setFieldValue is called and synchronous validation passes, the async validator runs automatically. While it is running, FieldState.isValidating is true.

What's Next ​

Released under the MIT License.