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

How I Eliminated All The Local Fields & Controllers in Flutter Form. Part 0: The Overview.
Powerful Bloc but with slow controllers.

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:

  1. Create the controller in initState
  2. Sync it in _onStarted
  3. Update it when BLoC state changes
  4. 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 didUpdateWidget and addPostFrameCallback
  • 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!