rrousselGit / riverpod

A reactive caching and data-binding framework. Riverpod makes working with asynchronous code a breeze.
https://riverpod.dev
MIT License
6.17k stars 943 forks source link

Handling Generic Form Fields with Riverpod 2.0: Seeking Guidance or Example #2440

Closed aliaksei-liavonik closed 1 year ago

aliaksei-liavonik commented 1 year ago

Describe what scenario you think is uncovered by the existing examples/articles I've tried to implement form field state management using the riverpod notifiers. I've started doing this using StateNotifier, which is legacy and will be removed. In the docs v2.0 there is info to use notifier instead. Using the StateNotifier there was the possibility to use it without a provider for example using StateNotifierBuilder form flutter_state_notifier package, which was really desired in my case because fields have a generic type of value and a generic type of error, but providers are global and don't provide generic types.

Describe why existing examples/articles do not cover this case Here are some important details about the form:

abstract class Form extends StateNotifier<FormState>
    with Disposable {
  Form({
    required this.validateAll,
  }) : super(const FormState()) {
    _registerFields(fields);

    addDisposable(_fieldsController.close);
    addDisposable(() => _onFieldsChangeSubscription?.cancel());
    addDisposable(
      onValuesChangedStream.listen((_) => _onFieldsStateChanged()).cancel,
    );
  }

  @override
  FormState get state => super.state;

  List<FormFieldNotifier<dynamic, dynamic>> get fields;

  final bool validateAll;

  StreamSubscription? _onFieldsChangeSubscription;
  final _fieldsController = StreamController<void>.broadcast();

  Stream<void> get onValuesChangedStream => _fieldsController.stream;
  ...
  void _registerFields(List<FormFieldNotifier<dynamic, dynamic>> fields) {
    addDisposable(() => fields.map((e) => e.dispose()));

    _supscribeFields(fields);

    _fieldsController.add(null);
  }

  void _supscribeFields(
    List<FormFieldNotifier<dynamic, dynamic>> fields,
  ) {
    _onFieldsChangeSubscription = Rx.merge<dynamic>(
      fields.map(
        (field) {
          return field.stream
              .map<dynamic>((state) => state.value)
              .distinctWithFirst(field.state.value);
        },
      ),
    ).listen(_fieldsController.add);
  }

  void _onFieldsStateChanged() {
    if (validateAll) {
      validateWithAutovalidate();
    }
    state = _FormState(
      wasModified: fields.any((element) => element.state.wasModified),
    );
  }
}

@freezed
class FormState with _$FormState {
  const factory FormState({
    @Default(false) bool wasModified,
  }) = _FormState;
}

And about the form fields:

typedef Validator<T, E extends Object> = E? Function(T);

class FormFieldNotifier<T, E extends Object>
    extends StateNotifier<FormFieldNotifierState<T, E>> {
  FormFieldNotifier({
    required T initialValue,
    Validator<T, E>? validator,
  })  : _validator = validator ?? ((_) => null),
        super(
          FormFieldNotifierState<T, E>(
            value: initialValue,
            initialValue: initialValue,
          ),
        );

  final Validator<T, E> _validator;

  @override
  FormFieldNotifierState<T, E> get state => super.state;
  ...
}

@freezed
class FormFieldNotifierState<T, E extends Object>
    with _$FormFieldNotifierState<T, E> {
  const factory FormFieldNotifierState({
    required T initialValue,
    required T value,
    @Default(null) E? error,
    @Default(false) bool autovalidate,
    @Default(false) bool readOnly,
  }) = _FormFieldNotifierState<T, E>;
  const FormFieldNotifierState._();

  bool get wasModified => initialValue != value;
}

And this has been used in this way:

final homeScreenFormProvider = Provider.autoDispose(
  (ref) => HomeScreenForm(
    validateAll: false,
  ),
);

class HomeScreenForm extends Form {
  HomeScreenForm({
    required super.validateAll,
  });

  final sort =
      FormFieldNotifier<HomeScreenSortFilterOption, HomeScreenFormError>(
    initialValue: HomeScreenSortFilterOption.published,
  );
  final dateAdded = FormFieldNotifier<HomeScreenDateAddedFilterOption,
      HomeScreenFormError>(
    initialValue: HomeScreenDateAddedFilterOption.newest,
  );

  @override
  List<FormFieldNotifier> get fields => [sort, dateAdded];
}

And the business logic accessed the values of the fields on the actions through the form properties as StateNotifiers, and reactive UI was built using the already mentioned StateNotifierBuilder where has been passed FieldStateNotifier

Additional context The first line of the flutter pub docs of riverpod says, that this is a state-management library, then there should be the possibility to make this logic works. I had the same logic using the bloc package, and that was natural to create form fields as cubits and I didn't have to use some other external packages to be able to watch the state.

rrousselGit commented 1 year ago

Keep form state outside of providers. Keep it local to your form widgets, using either a StatefulWidget or HookWidget (flutter_hooks). That's why flutter_hooks is considered a friend of Riverpod.

Only put your form state inside providers after it has been submitted and validated.

Riverpod is for global state. But form is typically local state with a bunch of TextEditingControllers & co. That's where flutter_hooks is used, which is more about local state.

rrousselGit commented 1 year ago

Added a point about this in https://github.com/rrousselGit/riverpod/issues/1762

aliaksei-liavonik commented 1 year ago

I understand, that there is a way to use Flutter form, but it's so limited and doesn't cover cases such as server validation, and custom error handling depending on the other fields(and this is for sure not the answer, that I need to use another "state manager" as flutter form). I've faced some screens with more than 100 fields as the business requirement, and it would be a nightmare to mix form logic with widget classes. Don't you think that the state management package should make it possible to take care of "local" states and not bind absolutely to the providers? It absolutely separated things, but boundaries make problems like mine

There are different cases, and sometimes you want to have logic locally as in the case I described here, but it looks like in the riverpod 2.0 with desired Notifier class you can't achieve it anymore as it was possible with StateNotifier(to use it without a provider).

AAverin commented 1 year ago

The advice to keep state local is a great one. But it isn't possible if your "submit" button is not local to the form. Example could be a Stepper with multiple Steps inside, where every Step has its own form with validation, triggered when the global Continue button is pressed.

Is it possible to use hooks inside Providers? I would love to have a single provider that would store all my textEditingControllers. That could be passed into Steps and used for validation and for submition.