What's new on Flutter FormX 3.0.0

What's new on Flutter FormX 3.0.0

As is the natural process of software development, we have noticed a big opportunity of refactoring Flutter FormX to support different apps’ architectures with the same ease as we did for MobX. That's what we understand as delivering value to the community.

With that in mind, we have updated FormX to support both MobX and Bloc state management solutions sharing the same vanilla implementation underneath so we guarantee it behaves the same way and it's easier to expand, as well as using FormX even if your app don't rely on any of the already supported state management solutions.

This is a big change because we have repurposed the library's main class, FormX, to be our vanilla implementation. The MobX one will be called FormXMobX from now on and you should replace your mixins with it.

Well, enough with the updates. I'm here to tell you what happened, why it happened and how it has come to this, in detail.

The starting point

Let me give you some more context. Flutter FormX is a library that makes it easy to build, react and validate forms in Flutter apps. It was created from a business need in Revelo and, because of that, it was first designed to work very well in apps that use MobX as their state management solution.

You can implement and validate forms on Flutter apps using Flutter FormX. We aim to be your go-to solution if you need forms in your app.

In the last version, 2.1.0, FormX looked like this:

import 'package:flutter_formx/src/form/form_x_field.dart';
import 'package:mobx/mobx.dart';

/// [FormX] is a helper class to handle forms. [T] stands for the type used to identify the fields
/// such as an enum or a string to later access each of its fields.
///
/// The first step is to call [setupForm] with a map of the fields, their initial values and
/// validators.
///
/// The second step is to call [updateAndValidateField] or [updateField] to update the value of
/// each field once it's changed on the UI and present its value on screen by using either the
/// helper functions such as [getFieldValue] or [getFieldErrorMessage] or by accessing the inputMap
/// directly.
///
/// The third and final step is to call [validateForm] to validate all fields and use the computed
/// value [isFormValid] to show a submit button as enabled or disabled and verify the status of the
/// form.
mixin FormX<T> {
  /// The map of fields, along with all of their properties
  @observable
  final ObservableMap<T, FormXField> inputMap = <T, FormXField>{}.asObservable();

  /// The computed value of the form, true if all fields are valid, false otherwise
  @computed
  bool get isFormValid => inputMap.values.every((element) => element.isValid);

  /// Sets up the form with the given inputs
  ///
  /// This method should be called when starting the viewModel and it already validates the form
  /// without applying any error messages.
  Future<void> setupForm(Map<T, FormXField> inputs) {
    inputMap.addAll(inputs);
    return validateForm(softValidation: true);
  }

  /// Updates the value of the field and validates it, updating the computed variable [isFormValid].
  /// When [softValidation] is true, it doesn't add errors messages to the fields, but updates the
  /// computed variable [isFormValid] which can be used to show a submit button as enabled or disabled
  @action
  Future<void> updateAndValidateField(
    dynamic newValue,
    T type, {
    bool softValidation = false,
  }) async {
    inputMap[type] = await inputMap[type]!
        .updateValue(newValue)
        .validateItem(softValidation: softValidation);
  }

  /// Updates the value of the field without validating it, this does not update the computed variable [isFormValid]
  @action
  void updateField(dynamic newValue, T type) {
    inputMap[type] = inputMap[type]!.updateValue(newValue);
  }

  /// Validates all fields in the form
  ///
  /// Returns bool indicating if the form is valid and when [softValidation] is true,
  /// it doesn't add errors messages to the fields, but updates the computed variable [isFormValid]
  /// which can be used to show a submit button as enabled or disabled
  @action
  Future<bool> validateForm({bool softValidation = false}) async {
    await Future.forEach(inputMap.keys, (type) async {
      inputMap[type] =
          await inputMap[type]!.validateItem(softValidation: softValidation);
    });
    return isFormValid;
  }

  /// Returns the value of the field
  V getFieldValue<V>(dynamic key) => inputMap[key]?.value as V;

  /// Returns the error message of the field
  String? getFieldErrorMessage(dynamic key) => inputMap[key]?.errorMessage;
}

As you can notice, this implementation was heavily dependent on MobX, had all of the form logic in it and would not be usable by an app with any other state management solution.

The need for change: supporting more architectures

When developing an open source library, the main goals are usually to make it accessible to as many people as possible and actually solve problems faced by the developer community. Even though MobX is the state management solution we decided to use on Revelo's apps, we knew that there were other solutions with more adopters. With that and the goal to expand the library to reach more developers in mind, we started thinking about how to make Flutter FormX support these other state management solutions.

Decisions, decisions

Ok, but with which one should we start? We studied which of the solutions were most used by the community and reached the conclusion that Bloc would be the best way to go.

There's a catch though: we as a team had never used Bloc in our apps. We had to learn that to be able to add it to FormX, and so we did.

Iterating implementations

Now we had to think about how we could make our code in a way that every new state management implementation would have the same syntax and methods, even though they could be different amongst them.

We then decided that in order to do that, we would need to transform FormX into an abstract class with the methods we would need for managing the form's state, updating, and validating fields. Also, we extracted a state class specifically to hold the form state, since Bloc uses a state separately.

import 'package:flutter_formx/src/form/formx_field.dart';

abstract class FormX<T> {
 
  Future<void> setupForm(Map<T, FormXField> inputs);
 
  Future<void> updateAndValidateField(
    dynamic newValue,
    T type, {
    bool softValidation = false,
  });

  void updateField(dynamic newValue, T type);

  Future<bool> validateForm({bool softValidation = false});
}
import 'package:flutter_formx/src/form/formx_field.dart';

abstract class FormXState<T> {
  final Map<T, FormXField> inputMap = {};

  bool get isFormValid => inputMap.values.every((element) => element.isValid);

  V getFieldValue<V>(dynamic key) => inputMap[key]?.value as V;

  String? getFieldErrorMessage(dynamic key) => inputMap[key]?.errorMessage;
}

This change made it necessary for us to update our MobX implementation, renaming it to FormXMobX. The implementation was the same, the only thing that got different was that it was now a mixin implementing both FormX and FormXState.

As you might notice as well, this was the first breaking change in our update.

After guaranteeing our MobX implementation still worked as intended, we turned our focus onto the Bloc implementation. Two classes came out, FormXCubit and FormXState, as follows.

import 'package:bloc/bloc.dart';
import 'package:flutter_formx/src/form/bloc/formx_cubit_state.dart';
import 'package:flutter_formx/src/form/formx.dart';
import 'package:flutter_formx/src/form/formx_field.dart';

/// Bloc implementation of [FormX]
class FormXCubit<T> extends Cubit<FormXCubitState<T>> implements FormX<T> {
  /// When FormXCubit is instantiated, it emits the initial state of the form.
  FormXCubit() : super(const FormXCubitState());

  /// Bloc implementation of [FormX.setupForm].
  @override
  Future<void> setupForm(Map<T, FormXField> inputs) {
    emit(FormXCubitState<T>(inputs));
    return validateForm(softValidation: true);
  }

  Map<T, FormXField> get _cloneStateMap =>
      Map<T, FormXField>.from(state.inputMap);

  /// Bloc implementation of [FormX.updateAndValidateField].
  @override
  Future<void> updateAndValidateField(
    dynamic newValue,
    T type, {
    bool softValidation = false,
  }) async {
    final inputMap = _cloneStateMap;
    inputMap[type] = await inputMap[type]!
        .updateValue(newValue)
        .validateItem(softValidation: softValidation);
    emit(FormXCubitState(inputMap));
  }

  /// Bloc implementation of [FormX.updateField].
  @override
  void updateField(dynamic newValue, T type) {
    final inputMap = _cloneStateMap;
    inputMap[type] = inputMap[type]!.updateValue(newValue);
    emit(FormXCubitState(inputMap));
  }

  /// Bloc implementation of [FormX.validateForm].
  @override
  Future<bool> validateForm({bool softValidation = false}) async {
    final inputMap = _cloneStateMap;
    await Future.forEach(inputMap.keys, (type) async {
      inputMap[type] =
          await inputMap[type]!.validateItem(softValidation: softValidation);
    });
    emit(FormXCubitState<T>(inputMap));
    return state.isFormValid;
  }
}
import 'package:equatable/equatable.dart';
import 'package:flutter_formx/src/form/formx_field.dart';
import 'package:flutter_formx/src/form/formx_state.dart';

/// Bloc state implementation of [FormXState]
class FormXCubitState<T> extends Equatable implements FormXState<T> {
  /// Bloc implementation of [FormXState.inputMap].
  /// This is an observable map of fields
  @override
  final Map<T, FormXField> inputMap;

  /// This class should receive an input map.
  const FormXCubitState([this.inputMap = const {}]);

  /// Bloc implementation of [FormXState.getFieldValue].
  @override
  V getFieldValue<V>(dynamic key) => inputMap[key]?.value as V;

  /// Bloc implementation of [FormXState.getFieldErrorMessage].
  @override
  String? getFieldErrorMessage(dynamic key) => inputMap[key]?.errorMessage;

  /// Bloc implementation of [FormXState.isFormValid].
  @override
  bool get isFormValid => inputMap.values.every((element) => element.isValid);

  @override
  List<Object> get props => [inputMap];
}

Remember, this was made based on our recently acquired knowledge of Bloc, and we were happy with it. It worked as expected and we could easily replicate our Example app with it.

However, we want to make good, easy-to-understand, and working code that will be used by as many people as possible. In order to do that, we felt like we needed more. We then reached out to several developers we know who work with Bloc and Flutter, asking them to be our “beta testers” and give us direct feedback on what we've done before releasing it to everyone.

Getting valuable feedback from other developers

We have tested our Bloc implementation with several Flutter developers who use Bloc on a daily basis and their feedback was amazing. With them, we were able to make it work perfectly. They have played a crucial part in the development of this huge refactor by provoking us in the right way: “Why don't you make an agnostic version already? It's almost there!”. Special thanks to Rafaela and Indaband devs for this!

This was the push we needed. We were already uncomfortable with having methods with repeated implementations, and worried that it could be hard for the library to have the reach we wanted without us having to spend countless hours developing implementations for every state management solution out there.

That's where it clicked. “Let's make a vanilla implementation of FormX, state management agnostic, so that people can use it with ease in every app, and let them manage the state. We provide all the rest.”

With that, it was ON! We were on fire with excitement and started the complete overhaul of FormX. It was hard but definitely worth it.

I believe the main take here is: if you have the opportunity to get outside feedback, just do it. It can be a turning point for you, as it was for us.

The result

After this story, you may be wanting to see how it finally turned out. I know, I know. Let me show you!

Firstly, we have the vanilla implementation, which has the form methods like updating a field or validating the form, holds the form state and is the new FormX.

import 'package:equatable/equatable.dart';
import 'package:flutter_formx/src/form/core/formx_field.dart';
import 'package:flutter_formx/src/form/core/formx_state.dart';

class FormX<T> extends Equatable {
  /// This field holds the current FormXState
  late final FormXState<T> state;

  /// Creates an empty FormX instance
  factory FormX.empty() => FormX<T>._();

  /// This method is used to setup the form with the provided initial values
  factory FormX.setupForm(Map<T, FormXField> inputs) => FormX<T>._(inputs);

  /// This method is used to create a FormX instance from a FormXState.
  /// The inputMap used will be the one inside the state
  factory FormX.fromState(FormXState<T> state) => FormX<T>._(state.inputMap);

  /// The FormX constructor.
  /// You should not use it directly, but instead use one of the factory
  /// constructors
  FormX._([Map<T, FormXField<dynamic>>? inputMap]) {
    state = FormXState<T>(inputMap ?? {});
  }

  /// Returns a new FormX with the updated value of the field and validates it,
  /// updating the value of [FormXState.isFormValid].
  ///
  /// When [softValidation] is true, it doesn't add errors messages to the
  /// fields, but updates the value of [FormXState.isFormValid] which can be
  /// used to show a submit button as enabled or disabled.
  Future<FormX<T>> updateAndValidateField(
    newValue,
    T key, {
    bool softValidation = false,
  }) async {
    final inputMap = _cloneStateMap;
    inputMap[key] = await inputMap[key]!
        .updateValue(newValue)
        .validateItem(softValidation: softValidation);

    return FormX<T>._(inputMap);
  }

  /// Returns a new instance of FormX with the new value of the field without
  /// validating it, which means this will not update the value of
  /// [FormXState.isFormValid].
  FormX<T> updateField(newValue, T key) {
    final inputMap = _cloneStateMap;
    inputMap[key] = inputMap[key]!.updateValue(newValue);
    return FormX<T>._(inputMap);
  }

  /// Validates all fields in the form
  ///
  /// Returns a new state with all fields validated and when [softValidation] is
  /// true, it doesn't add errors messages to the fields, but updates the value
  /// of [FormXState.isFormValid]  which can be used to show a submit button as
  /// enabled or disabled
  Future<FormX<T>> validateForm({bool softValidation = false}) async {
    final inputMap = _cloneStateMap;
    await Future.forEach(inputMap.keys, (key) async {
      inputMap[key] =
          await inputMap[key]!.validateItem(softValidation: softValidation);
    });

    return FormX<T>._(inputMap);
  }

  Map<T, FormXField> get _cloneStateMap =>
      Map<T, FormXField>.from(state.inputMap);

  @override
  List<Object?> get props => [state];
}

Note that we have kept a state class, FormXState, which holds the current state of FormX and will be the one to be accessed if you need anything from the form, like if the form is valid or not.

import 'package:equatable/equatable.dart';
import 'package:flutter_formx/src/form/core/formx_field.dart';

/// [FormXState] is a class that holds the state of a form.
///
/// You can present the fields' values on screen by using either the helper
/// functions such as [FormXState.getFieldValue] or
/// [FormXState.getFieldErrorMessage] or by accessing the inputMap directly.
///
/// You can also use the computed value [FormXState.isFormValid] to show a
/// submit button as enabled or disabled and verify the status of the form.
class FormXState<T> extends Equatable {
  final Map<T, FormXField> _inputMap;

  /// Creates a [FormXState] instance
  const FormXState([this._inputMap = const {}]);

  /// Gets an unmodifiable instance of the inputMap
  Map<T, FormXField> get inputMap => Map.unmodifiable(_inputMap);

  /// Gets a field value by its key
  V getFieldValue<V>(T key) => _inputMap[key]?.value as V;

  /// Gets a field error message by its key. It will return null if the field is valid
  String? getFieldErrorMessage(T key) => _inputMap[key]?.errorMessage;

  /// Returns true if all fields are valid
  bool get isFormValid => _inputMap.values.every((element) => element.isValid);

  @override
  List<Object?> get props => [_inputMap];
}

With this approach, we could avoid duplicate code in a neat way when adding new state management support, which we'll cover now.

Supporting state management approaches

To keep supporting the state management solutions, we decided to follow the Adapter Design Pattern, which makes it's easy to create new adapters for them, who will need to implement the FormXAdapter interface.

import 'package:flutter_formx/src/form/core/formx_field.dart';

/// Adapter for [FormX].
/// The state management implementations should implement this class
abstract class FormXAdapter<T> {

  void setupForm(Map<T, FormXField> inputs, {bool applySoftValidation = true});

  Future<void> updateAndValidateField(
    dynamic newValue,
    T key, {
    bool softValidation = false,
  });

  void updateField(dynamic newValue, T key);

  Future<void> validateForm({bool softValidation = false});
}

With that, we have the following adapters:

MobX


import 'package:flutter_formx/src/form/adapters/formx_adapter.dart';
import 'package:flutter_formx/src/form/core/formx.dart';
import 'package:flutter_formx/src/form/core/formx_field.dart';
import 'package:flutter_formx/src/form/core/formx_state.dart';
import 'package:mobx/mobx.dart';

/// MobX implementation of [FormX]
mixin FormXMobX<T> implements FormXAdapter<T> {
  final Observable<FormX<T>> _formX = Observable(FormX.empty());

  /// Returns the current FormXState from this instance
  FormXState<T> get state => _formX.value.state;

  /// Returns the current validation status of the form from this instance's state
  bool get isFormValid => state.isFormValid;

  /// Gets a field value from the state by its key
  V getFieldValue<V>(T key) => state.getFieldValue(key);

  /// Gets a field error message from the state by its key.
  /// It will return null if the field is valid
  String? getFieldErrorMessage(T key) => state.getFieldErrorMessage(key);

  @override
  Future<void> setupForm(
    Map<T, FormXField> inputs, {
    bool applySoftValidation = true,
  }) async {
    Action(() {
      _formX.value = FormX.setupForm(inputs);
    })();
    if (applySoftValidation) {
      final validatedForm =
          await _formX.value.validateForm(softValidation: true);
      Action(() {
        _formX.value = validatedForm;
      })();
    }
  }

  @override
  Future<void> updateAndValidateField(
    newValue,
    key, {
    bool softValidation = false,
  }) async {
    final validatedField = await _formX.value
        .updateAndValidateField(newValue, key, softValidation: softValidation);
    Action(() {
      _formX.value = validatedField;
    })();
  }

  @override
  void updateField(newValue, key) {
    Action(() {
      _formX.value = _formX.value.updateField(newValue, key);
    })();
  }

  @override
  Future<bool> validateForm({bool softValidation = false}) async {
    final validatedForm =
        await _formX.value.validateForm(softValidation: softValidation);
    Action(() {
      _formX.value = validatedForm;
    })();
    return state.isFormValid;
  }
}

Bloc

import 'package:bloc/bloc.dart';
import 'package:flutter_formx/src/form/adapters/formx_adapter.dart';
import 'package:flutter_formx/src/form/core/formx.dart';
import 'package:flutter_formx/src/form/core/formx_field.dart';
import 'package:flutter_formx/src/form/core/formx_state.dart';

/// Bloc implementation of [FormX] with [FormXAdapter]
class FormXCubit<T> extends Cubit<FormXState<T>> implements FormXAdapter<T> {
  /// When FormXCubit is instantiated, it emits the initial state of the form.
  FormXCubit() : super(const FormXState({}));

  @override
  Future<void> setupForm(
    Map<T, FormXField> inputs, {
    bool applySoftValidation = true,
  }) async {
    emit(FormXState<T>(inputs));
    if (applySoftValidation) await validateForm(softValidation: true);
  }

  @override
  Future<void> updateAndValidateField(
    dynamic newValue,
    T key, {
    bool softValidation = false,
  }) async {
    final formX = await FormX.fromState(state)
        .updateAndValidateField(newValue, key, softValidation: softValidation);
    emit(formX.state);
  }

  @override
  void updateField(dynamic newValue, T key) {
    final formX = FormX.fromState(state).updateField(newValue, key);
    emit(formX.state);
  }

  @override
  Future<bool> validateForm({bool softValidation = false}) async {
    final formX = await FormX.fromState(state)
        .validateForm(softValidation: softValidation);
    emit(formX.state);
    return state.isFormValid;
  }
}

Note that both classes do not have any form logic inside them. They merely use FormX to do everything they need and manage the state according to their state management solution (MobX with observables and Cubit emitting new states).

If you wish to see them in action, check out our example app! It has the vanilla, MobX and Bloc implementations.

Making sure everything works

After all that refactoring, we still need to make sure that it works as intended and, most importantly, guarantee that future changes won't result in an unwanted behavior change.

That's why we have implemented unit tests for every class and also an integration test focused on FormX and its behavior.

We have also made a functioning form with every implementation (vanilla, MobX and Bloc) to guarantee in usability tests that they were working perfectly and there were no differences in behavior between state management solutions.

Yeap, this is all for you!

Why does this matter to you?

FormX is now state management agnostic

It will now be quicker to implement new adapters for different state management solutions since all the logic is inside FormX. The state management adapters will be just a shell to allow your state to react properly when anything changes in FormX.

Vanilla for the win!

With the vanilla FormX you'll be able to use FormX in your apps even if you don't use one of the already supported state management solutions. Just make your own state implementation based off of the vanilla FormX and build your forms with ease.

Easier to contribute

Have you made an interface using our vanilla FormX for a different state management solution and it works like a charm? Don't even think twice, let's make this library as complete as possible together.

Form Validation

We have maintained Validators as they were and all versions of FormX support their usage. Rest assured, we got your back!

We hope you could get an idea of how this process happened, what it took for us to make this change to FormX and, mainly, why we did what we did. We're excited for the next steps and really hope that you can take part in them!