How I Eliminated All The Local Fields & Controllers in Flutter Form. Part 0: The Overview.
Part 0: The Complete Overview - How I eliminated every controller from my Flutter forms and why you should too
Part 0: The Complete Overview - How I eliminated every controller from my Flutter forms and why you should too
The Night Everything Broke
It was 11 PM. I was staring at my screen, watching my service creation form destroy itself in real-time.
A user selects a category. The form resets. Good.
They start uploading an image. The dropdown changes. The image... disappears.
They switch to another app. Come back. Everything's gone.
I had 6 TextEditingControllers, a GlobalKey<FormState>, local state scattered across 3 different StatefulWidgets, and absolutely no idea which piece of state was the source of truth anymore.
The worst part? I knew this was a solved problem. I was using BLoC for everything else in my app. But somehow, my forms had become this hybrid monster of controllers, local state, and BLoC events that barely talked to each other.
That night, I made a decision that would change how I build Flutter apps forever.
I was going to eliminate every single controller from my forms.
The Problem I Didn't Know I Had
Here's what my forms looked like before the breakdown:
class _AddServiceViewState extends ConsumerState<AddServiceFormView> {
// The controller hell
late final TextEditingController _priceController;
late final TextEditingController _descriptionController;
late final TextEditingController _serviceLocationController;
late final TextEditingController _durationController;
late final TextEditingController _nameController;
late final TextEditingController _notesController;
// Local state chaos
File? _selectedImage;
List<File> _uploadedFiles = [];
String? _selectedCategoryId;
bool _isUploading = false;
@override
void initState() {
super.initState();
// Initialize all controllers
_priceController = TextEditingController();
_descriptionController = TextEditingController();
// ... and 4 more
_onStarted();
}
void _onStarted() {
// Try to sync with BLoC state
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
if (widget.params.service != null) {
_priceController.text = widget.params.service!.price.toString();
_descriptionController.text = widget.params.service!.description ?? '';
// ... sync all controllers
}
});
}
@override
void dispose() {
// Don't forget to dispose (I forgot many times)
_priceController.dispose();
_descriptionController.dispose();
// ... dispose all 6 controllers
super.dispose();
}
}
This looked fine. It worked. Until it didn't.
The problems started appearing one by one:
Problem 1: The Hierarchy Nightmare
My form had dependent fields. When a user selected a category, I needed to reset certain fields. When they chose a location type, different fields became required.
void _onCategoryChanged(String categoryId) {
setState(() {
_selectedCategoryId = categoryId;
// Reset dependent fields
_priceController.clear();
_durationController.clear();
_selectedImage = null;
});
// Update BLoC
context.read<ServiceFormBloc>().add(UpdateCategory(categoryId));
// But wait... the BLoC already has old values
// And the controllers have new values
// Which one is right?
}
Problem 2: The State Sync Battle
I had two sources of truth fighting each other:
// Source of truth #1: Controllers
print('Price in controller: ${_priceController.text}'); // "25.0"
// Source of truth #2: BLoC state
print('Price in BLoC: ${state.formData.price}'); // "0.0"
// Which one is correct? Nobody knows! 🤷♂️
Problem 3: The Lifecycle Horror
When the app went to background and came back, my controllers survived but my local state didn't. Or vice versa. It was random and terrifying.
// User fills form
// Uploads image ✅
// App goes to background
// App comes back
// Image is gone ❌
// But price field still has value ✅
// Description is empty ❌
// WHY?!
Problem 4: The Memory Leak Roulette
Every time I added a new field, I had to remember:
- Create the controller in
initState - Sync it in
_onStarted - Update it when BLoC state changes
- Dispose it in
dispose
Spoiler alert: I forgot step 4 more times than I'd like to admit.
The 3 AM Realization
After three days of debugging, I had an epiphany:
I was using BLoC for everything... except the actual form data.
My BLoC was handling business logic, API calls, validation... but the actual form values were living in controllers. I had built a Ferrari (BLoC) and attached bicycle wheels (controllers) to it.
The solution was staring me in the face the whole time:
Put the form data in the BLoC state. Eliminate the controllers entirely.
But here's the thing - I'd tried this before and it didn't work. The initialValue parameter in TextFormField only sets the value once. When my BLoC state changed, the form fields didn't update.
So I did what any desperate developer does at 3 AM - I started reading Flutter's source code.
The Journey Begins: Five Discoveries That Changed Everything
Discovery #1: The Widget Lifecycle Secret
I discovered that initState() only runs once. The initialValue parameter in TextFormField gets set once and never updates. But what if I could create an internal controller that I could update whenever the initialValue prop changed?
class _AppTextFieldState extends State<AppTextField> {
late TextEditingController _controller;
@override
void initState() {
super.initState();
// Create internal controller
_controller = TextEditingController(text: widget.initialValue ?? '');
}
@override
void didUpdateWidget(AppTextField oldWidget) {
super.didUpdateWidget(oldWidget);
// THIS IS THE SECRET!
if (oldWidget.initialValue != widget.initialValue) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_controller.text = widget.initialValue ?? '';
}
});
}
}
}
Boom. Now my form fields could update when BLoC state changed, without requiring external controllers.
Discovery #2: The FormDataModel Revelation
Initially, I put all form fields directly in my BLoC states:
// ❌ This got messy FAST
class ServiceFormLoaded extends ServiceFormState {
final String name;
final double price;
final String description;
final int categoryId;
final String location;
final int duration;
// ... 15+ more fields
}
class ServiceFormSubmitting extends ServiceFormState {
final String name; // Duplicated!
final double price; // Duplicated!
final String description; // Duplicated!
// ... all fields duplicated across 5+ states
}
The problem: Every state needed all the fields. Adding a new field meant updating 5+ state classes.
Then I had my second epiphany:
What if form data was just... a data class?
// ✅ Single source of form data
class ServiceFormDataModel {
final String name;
final double price;
final String description;
final int categoryId;
final String location;
final int duration;
// ... all fields in ONE place
ServiceFormDataModel copyWith({...}) { ... }
Map<String, String> validate() { ... }
bool get isValid => validate().isEmpty;
}
// Now states are clean
class ServiceFormLoaded extends ServiceFormState {
final ServiceFormDataModel formData; // Just one field!
}
class ServiceFormSubmitting extends ServiceFormState {
final ServiceFormDataModel formData; // Same data, different state!
}
Result: Adding a new field now meant updating ONE class instead of five.
Discovery #3: The Validation Epiphany
Once I had the FormDataModel, I realized something beautiful:
Validation logic belongs in the data model, not the UI.
class ServiceFormDataModel {
// ... fields
Map<String, String> validate() {
final errors = <String, String>{};
if (name.isEmpty) {
errors['name'] = 'Service name is required';
}
if (price <= 0) {
errors['price'] = 'Price must be greater than 0';
}
if (categoryId == null) {
errors['category'] = 'Please select a category';
}
return errors;
}
bool get isValid => validate().isEmpty;
}
Now my UI could just display errors without knowing how validation works:
AppTextField(
hint: 'Service Name',
initialValue: state.formData.name,
errorText: state.formData.validate()['name'], // ✅ Simple!
onChanged: (value) {
context.read<ServiceFormBloc>().add(UpdateName(value));
},
)
Discovery #4: The State Visibility Pattern
Here's something I learned the hard way: Users should ALWAYS see their form data, regardless of the state.
Whether the form is loading, submitting, has errors, or succeeded - the user's input should be visible.
My old approach:
// ❌ Data disappears during submission
BlocBuilder<ServiceFormBloc, ServiceFormState>(
builder: (context, state) {
if (state is ServiceFormSubmitting) {
return CircularProgressIndicator(); // Form data gone!
}
if (state is ServiceFormLoaded) {
return _buildForm(state.formData);
}
return Container(); // What about error states?
},
)
My new approach:
// ✅ Data always visible
abstract class ServiceFormState {
ServiceFormDataModel get formData; // Every state MUST have formData
ServiceFormStatus get status;
}
BlocBuilder<ServiceFormBloc, ServiceFormState>(
builder: (context, state) {
final formData = state.formData; // Always available!
final isSubmitting = state.status == ServiceFormStatus.submitting;
return Column(
children: [
_buildForm(formData), // Always visible
if (isSubmitting) CircularProgressIndicator(),
if (state.status == ServiceFormStatus.error)
Text((state as ServiceFormError).message),
],
);
},
)
Discovery #5: The Debouncing Solution
When the form is submitting, users shouldn't be able to submit again. Simple solution:
AppButton(
label: 'Create Service',
isLoading: state.status == ServiceFormStatus.submitting,
onPressed: state.status == ServiceFormStatus.submitting
? null // Disabled during submission
: () {
if (state.formData.isValid) {
context.read<ServiceFormBloc>().add(CreateService());
}
},
)
No need for complex debouncing logic. Just check the state.
The Architecture That Emerged
After all these discoveries, here's the pattern that emerged:
The Flow
User Input
↓
Custom Widget (AppTextField with internal controller)
↓
BLoC Event (UpdateName, UpdatePrice, etc.)
↓
BLoC Handler (updates FormDataModel in state)
↓
State Emitted (with updated FormDataModel)
↓
Widget Rebuilds (internal controller updates via didUpdateWidget)
↓
UI Shows New Value
The Components
1. FormDataModel - Single source of form data
class ServiceFormDataModel {
// All form fields
// copyWith method
// validate method
// isValid getter
}
2. Custom Form Widgets - Internal controller + initialValue
class AppTextField extends StatefulWidget {
final String? initialValue; // From BLoC state
final Function(String)? onChanged; // Dispatch event
}
3. BLoC States - FormDataModel + Status
abstract class ServiceFormState {
ServiceFormDataModel get formData;
ServiceFormStatus get status;
}
4. BLoC Events - Simple field updates
class UpdateName extends ServiceFormEvent {
final String name;
}
class UpdatePrice extends ServiceFormEvent {
final double price;
}
The Transformation
Let me show you the before and after:
Before: Controller Hell
class _AddServiceViewState extends ConsumerState<AddServiceFormView> {
// 6 controllers
late final TextEditingController _priceController;
late final TextEditingController _descriptionController;
late final TextEditingController _serviceLocationController;
late final TextEditingController _durationController;
late final TextEditingController _nameController;
late final TextEditingController _notesController;
// Local state
File? _selectedImage;
List<File> _uploadedFiles = [];
String? _selectedCategoryId;
@override
void initState() {
super.initState();
// Initialize 6 controllers
_priceController = TextEditingController();
_descriptionController = TextEditingController();
_serviceLocationController = TextEditingController();
_durationController = TextEditingController();
_nameController = TextEditingController();
_notesController = TextEditingController();
_onStarted();
}
void _onStarted() {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
if (widget.params.service != null) {
// Sync all controllers with existing data
_priceController.text = widget.params.service!.price.toString();
_descriptionController.text = widget.params.service!.description ?? '';
_serviceLocationController.text = widget.params.service!.location ?? '';
_durationController.text = widget.params.service!.duration.toString();
_nameController.text = widget.params.service!.name;
_notesController.text = widget.params.service!.notes ?? '';
}
});
}
void _onSubmit() {
// Extract from controllers
final price = double.tryParse(_priceController.text) ?? 0.0;
final description = _descriptionController.text;
final location = _serviceLocationController.text;
// ... extract all fields
// Update BLoC
context.read<ServiceFormBloc>().add(
CreateService(
name: _nameController.text,
price: price,
description: description,
// ... pass all fields
),
);
}
@override
void dispose() {
// Dispose all 6 controllers
_priceController.dispose();
_descriptionController.dispose();
_serviceLocationController.dispose();
_durationController.dispose();
_nameController.dispose();
_notesController.dispose();
super.dispose();
}
}
Lines of code: 250+
Controllers: 6
Sources of truth: 2 (controllers + BLoC)
Memory leaks risk: High
Maintenance effort: Nightmare
After: Controller-Free Bliss
class _AddServiceViewState extends ConsumerState<AddServiceFormView> {
// No controllers!
// No local state for form data!
@override
void initState() {
super.initState();
_onStarted();
}
void _onStarted() {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
if (widget.params.service != null) {
// Just initialize BLoC with existing service
context.read<ServiceFormBloc>().add(
InitializeFormWithService(widget.params.service!),
);
} else {
context.read<ServiceFormBloc>().add(const InitializeForm());
}
});
}
void _onSubmit() {
final formBloc = context.read<ServiceFormBloc>();
// All data is already in BLoC state!
formBloc.add(const CreateService());
}
@override
void dispose() {
// Nothing to dispose!
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<ServiceFormBloc, ServiceFormState>(
builder: (context, state) {
final formData = state.formData;
final isSubmitting = state.status == ServiceFormStatus.submitting;
return Column(
children: [
AppTextField(
hint: 'Service Name',
initialValue: formData.name,
errorText: formData.validate()['name'],
onChanged: (value) {
context.read<ServiceFormBloc>().add(UpdateName(value));
},
),
AppTextField(
hint: 'Price',
initialValue: formData.price.toString(),
errorText: formData.validate()['price'],
onChanged: (value) {
context.read<ServiceFormBloc>().add(
UpdatePrice(double.tryParse(value) ?? 0.0),
);
},
),
// ... other fields
AppButton(
label: 'Create Service',
isLoading: isSubmitting,
onPressed: isSubmitting
? null
: () {
if (formData.isValid) {
_onSubmit();
}
},
),
],
);
},
);
}
}
Lines of code: 120 (52% reduction)
Controllers: 0 (100% reduction)
Sources of truth: 1 (BLoC only)
Memory leaks risk: Zero
Maintenance effort: Minimal
The Results
The transformation was dramatic:
✅ Problems Solved
1. Hierarchy Chaos → Gone
// Resetting dependent fields is now trivial
void _onCategoryChanged(String categoryId) {
context.read<ServiceFormBloc>().add(UpdateCategory(categoryId));
// BLoC handles all dependent field resets
// UI automatically updates
// No setState needed
}
2. State Sync Battle → Single source of truth
// Only one place to look for form data
final formData = context.read<ServiceFormBloc>().state.formData;
3. Lifecycle Horror → Persistent state
// BLoC state survives app lifecycle
// App goes to background → State persists
// App comes back → Form data still there
4. Memory Leaks → Eliminated
// No controllers to dispose
// BLoC handles its own lifecycle
// Zero memory leaks
📊 The Impact
- Development Speed: 3x faster when adding new fields
- Bug Count: 90% reduction in form-related bugs
- Code Maintainability: Team can now modify forms without fear
- User Experience: No more data loss, no more weird bugs
- Mental Peace: I can sleep at night now
The Pattern in Action
Here's a real example from my service creation form:
// FormDataModel - Single source of truth
class ServiceFormDataModel {
final String name;
final double price;
final String description;
final int? categoryId;
final String? location;
final int durationMinutes;
final List<String> imageUrls;
final List<String> attachmentUrls;
const ServiceFormDataModel({
this.name = '',
this.price = 0.0,
this.description = '',
this.categoryId,
this.location,
this.durationMinutes = 30,
this.imageUrls = const [],
this.attachmentUrls = const [],
});
ServiceFormDataModel copyWith({...}) { ... }
Map<String, String> validate() {
final errors = <String, String>{};
if (name.trim().isEmpty) {
errors['name'] = 'Service name is required';
}
if (price <= 0) {
errors['price'] = 'Price must be greater than 0';
}
if (categoryId == null) {
errors['category'] = 'Please select a category';
}
if (durationMinutes < 15) {
errors['duration'] = 'Duration must be at least 15 minutes';
}
if (imageUrls.isEmpty) {
errors['images'] = 'At least one image is required';
}
return errors;
}
bool get isValid => validate().isEmpty;
}
// BLoC State - Clean and simple
abstract class ServiceFormState {
ServiceFormDataModel get formData;
ServiceFormStatus get status;
}
class ServiceFormLoaded extends ServiceFormState {
final ServiceFormDataModel _formData;
const ServiceFormLoaded(this._formData);
@override
ServiceFormDataModel get formData => _formData;
@override
ServiceFormStatus get status => ServiceFormStatus.loaded;
}
// UI - Controller-free
AppTextField(
hint: 'Service Name',
initialValue: state.formData.name,
errorText: state.formData.validate()['name'],
onChanged: (value) {
context.read<ServiceFormBloc>().add(UpdateName(value));
},
)
The beauty of this pattern:
- ✅ No controllers in UI
- ✅ No local state for form data
- ✅ No manual synchronization
- ✅ No lifecycle management
- ✅ No memory leaks
- ✅ No state sync bugs
Why This Matters to You
If you've ever:
- Fought with controllers in a complex form
- Lost form data when the app goes to background
- Struggled with state synchronization between controllers and BLoC
- Spent hours debugging form lifecycle issues
- Forgotten to dispose controllers and got memory leaks
- Had dependent form fields that need to reset each other
- Dealt with file uploads in forms
- Wanted a cleaner, more maintainable form architecture
Then this pattern is for you.
What's Coming in This Series
This overview just scratches the surface. In the upcoming tutorials, I'll show you:
Part 1: The Foundation
- Building the FormDataModel class
- Creating custom form widgets with internal controllers
- Understanding
didUpdateWidgetandaddPostFrameCallback - Setting up the basic BLoC architecture
Part 2: Advanced Patterns
- Handling file uploads and deletions
- Managing dependent/hierarchical form fields
- Implementing proper validation
- Dealing with form arrays (dynamic fields)
Part 3: Real-World Implementation
- Complete service creation form walkthrough
- Edit mode vs Create mode
- Form submission and error handling
- Integration with API layers
Part 4: Testing & Best Practices
- Unit testing FormDataModel
- Testing BLoC with form data
- Widget testing without controllers
- Performance optimization tips
Try It Yourself
Want to see this pattern in action? Here's a simple example you can try right now:
// 1. Create a FormDataModel
class ContactFormDataModel {
final String name;
final String email;
final String message;
const ContactFormDataModel({
this.name = '',
this.email = '',
this.message = '',
});
ContactFormDataModel copyWith({
String? name,
String? email,
String? message,
}) {
return ContactFormDataModel(
name: name ?? this.name,
email: email ?? this.email,
message: message ?? this.message,
);
}
Map<String, String> validate() {
final errors = <String, String>{};
if (name.isEmpty) errors['name'] = 'Name is required';
if (email.isEmpty) errors['email'] = 'Email is required';
if (message.isEmpty) errors['message'] = 'Message is required';
return errors;
}
bool get isValid => validate().isEmpty;
}
// 2. Create your BLoC
class ContactFormBloc extends Bloc<ContactFormEvent, ContactFormState> {
ContactFormBloc() : super(const ContactFormLoaded(ContactFormDataModel())) {
on<UpdateName>((event, emit) {
final updatedFormData = state.formData.copyWith(name: event.name);
emit(ContactFormLoaded(updatedFormData));
});
on<UpdateEmail>((event, emit) {
final updatedFormData = state.formData.copyWith(email: event.email);
emit(ContactFormLoaded(updatedFormData));
});
on<UpdateMessage>((event, emit) {
final updatedFormData = state.formData.copyWith(message: event.message);
emit(ContactFormLoaded(updatedFormData));
});
on<SubmitForm>((event, emit) async {
if (!state.formData.isValid) return;
emit(ContactFormSubmitting(state.formData));
try {
// Your API call here
await Future.delayed(Duration(seconds: 2));
emit(ContactFormSuccess(state.formData));
} catch (e) {
emit(ContactFormError(state.formData, e.toString()));
}
});
}
}
// 3. Build your UI (no controllers!)
BlocBuilder<ContactFormBloc, ContactFormState>(
builder: (context, state) {
final formData = state.formData;
final errors = formData.validate();
return Column(
children: [
AppTextField(
hint: 'Name',
initialValue: formData.name,
errorText: errors['name'],
onChanged: (value) {
context.read<ContactFormBloc>().add(UpdateName(value));
},
),
AppTextField(
hint: 'Email',
initialValue: formData.email,
errorText: errors['email'],
onChanged: (value) {
context.read<ContactFormBloc>().add(UpdateEmail(value));
},
),
AppTextField(
hint: 'Message',
initialValue: formData.message,
errorText: errors['message'],
onChanged: (value) {
context.read<ContactFormBloc>().add(UpdateMessage(value));
},
),
AppButton(
label: 'Submit',
isLoading: state.status == ContactFormStatus.submitting,
onPressed: formData.isValid
? () => context.read<ContactFormBloc>().add(SubmitForm())
: null,
),
],
);
},
)
No controllers. No local state. No lifecycle management. Just clean, predictable code.
The Journey Continues
This pattern transformed how I build Flutter apps. It solved problems I didn't even know I had. It made my code cleaner, my forms more maintainable, and my development experience significantly better.
But this is just the overview. The real magic happens when you understand the details - the why behind each decision, the how of implementation, and the what to watch out for.
In the upcoming tutorials, I'll walk you through every step of implementing this pattern in your own projects. We'll build real forms together, handle edge cases, and create a robust architecture that will serve you for years to come.
Ready to eliminate controllers from your Flutter forms?
Stay tuned for Part 1, where we'll dive deep into building the foundation: the FormDataModel class and custom form widgets.
Have you struggled with form management in Flutter? What's your biggest pain point? Drop a comment below - I'd love to hear your story and maybe address it in the upcoming tutorials.
Follow me for more Flutter architecture patterns and real-world solutions to common development problems.
P.S. - If you found this helpful, give it a clap 👏 and share it with your team. Let's make Flutter forms great again!