Building Forms in Flutter

Georgi Stanev
Prime Holding JSC
Published in
5 min readFeb 23, 2021

Introduction

Although building forms in Flutter may seem like an easy task, separating the business logic from the UI layer can be a challenge. The separation of concerns makes your app more scalable and maintainable and most importantly the business (validation) logic becomes unit-testable, so let’s see how we can achieve this by using rx_bloc and flutter_rx_bloc. If you are new to rx_bloc, check out Introducing rx_bloc ecosystem. As usual, we will be using our lovely puppies sample.

Building the business logic

First, we need to declare the events and states contracts for the BloC we are about to implement. In our case, it will be the PuppyManageBloc that will be responsible for creating and updating a puppy.

puppy_manage_bloc.dart


abstract class PuppyManageEvents {
void setName(String name); void savePuppy();
...
}
abstract class PuppyManageStates { Stream<String> get name;
...
}

As you can see above, in the events contract we have two events, one for setting the name and one for saving the entity. Then in the states contract, we declare just the name of the puppy.

How to prefill the form?

The form for creating a puppy should be empty because the puppy has not been created yet, but when it comes to updating a puppy, we need to prefill all the fields related to the properties of the puppy that we want to update. For simplicity, let’s just showcase the puppy’s name.

puppy_manage_bloc.dart

@RxBloc()
class PuppyManageBloc extends $PuppyManageBloc {
PuppyManageBloc(
this.puppy,
...
})
@override
Stream<String> _mapToNameState() => _$setNameEvent
.startWith(puppy?.name ?? '')
...
}

In the event-to-state mapper _mapToNameState, we declare with the startWith operator that the first event that will be sent to the UI Layer will be the name of the puppy we are updating, which will prefill the text field. How easy is that?

Error handling

Now the question is how do we handle the errors if the puppy’s name is empty or too long? Fortunately, Dart Streams support errors (as explained here), and flutter_rx_bloc leverages this in RxFieldException which helps us show a user-friendly message next to the field where the error occurs.

puppy_manage_bloc.dart

@RxBloc()
class PuppyManageBloc extends $PuppyManageBloc {
PuppyManageBloc(
this.validator,
this.repository,
this.puppy,
...
})
final PuppyValidator validator; @override
Stream<String> _mapToNameState() => _$setNameEvent
.startWith(puppy?.name ?? '')
.map(validator.validatePuppyName);
}

The validation logic will be triggered on the fly when the user starts typing, as the events will sink into the _$setNameEventsubject by calling setName(name:). Now let’s see what the actual validation logic looks like

puppy_validator.dart

class PuppyValidator {  String validatePuppyName(String name) {
if (name == null || name.isEmpty) {
throw RxFieldException(
fieldValue: name,
error: 'Name must not be empty.',
);
}
if (name.trim().length > _maxNameLength) {
throw RxFieldException(
fieldValue: name,
error: 'Name is too long.',
);
}
return name;
}
}

In the validator, we handle two error scenarios “Name must be not empty” and “Name is too long". As you can see the validator either throws a RxFieldException or just returns the puppy’s name. Throwing exactly this type of exception is important because it allows us to combine the error message with the current value in the business logic, and consequently we can declare in the UI layer what the field should contain and what the error message should look like.

Building the UI layer

Our business layer is ready, so now we can start building the UI layer and all necessary bindings. Following best practices, we need to create a dedicated widget for the page and a widget for the form itself.

The page widget

This widget will be responsible for the DI (Dependency Injection), scaffold, and the app bar, so let’s see what the implementation looks like.

puppy_edit_page.dart

class PuppyEditPage extends StatelessWidget with AutoRouteWrapper {  final Puppy _puppy;  @override
Widget wrappedRoute(BuildContext context) =>
RxBlocProvider<PuppyManageBlocType>(
create: (context) => PuppyManageBloc(
puppy: puppy,
repository: context.read(),
validator: context.read(),
),
),
child: this,
);
@override
Widget build(BuildContext context) =>
Scaffold(
appBar: PuppyEditAppBar(),
body: RxUnfocuser(
PuppyEditForm(
puppy: puppy,
)
),
);
}

As you can see, we leverage the auto_router package, using the wrapped route along with the RxBlocProvider to inject the PuppyManageBloc. This way, this BloC will be available within the widget tree of the page and will also be automatically disposed of when the user leaves the page.

The form

The form widget should only be responsible for the form, which usually contains a column with children that will be the actual form inputs.

puppy_edit_form.dart

class PuppyEditForm extends StatelessWidget {
@override
Widget build(BuildContext context) => Column(
children: [
RxTextFormFieldBuilder<PuppyManageBlocType>(
state: (bloc) => bloc.states.name,
showErrorState: (bloc) => bloc.states.showErrors,
onChanged: (bloc, value) => bloc.events.setName(value),
builder: (fieldState) => TextFormField(
controller: fieldState.controller,
decoration: fieldState.decoration
.copyWithDecoration(InputStyles.textFieldDecoration),
),
)
...
]
}

For each text input, we use RxTextFormFieldBuilder, (or RxFormFieldBuilder) where we specify which BloC should be used (in our case PuppyManageBlocType). Then we need to specify the state of the BloC we want to listen to, the event to call when the user is typing, and the builder that usually returns a TextFormField but could also return any custom text input that has a decoration.

As an argument of the builder, we get a fieldState which contains a bunch of useful properties. One of the most useful properties is the controllerthat should be passed to the field, which works as a communication channel between the business layer and the UI layer. Another is the decoration, which defines how to be presented the error state, including the border, the message, and much more.

You might have noticed in the video above that the errors appear only when the user taps on the submit button, which is controlled by the propertyshowErrorState.

Architecture: the big picture

In conclusion, from the user’s perspective, the form looks simple, but as software engineers, we need to deal with a lot of things behind the scenes.

For this, we need tools like rx_bloc to help us build the business layer of the form and like flutter_rx_bloc to help us building the UI layer. Let’s see what an architectural diagram of our implementation looks like.

--

--