How We Handle Complex State in Flutter Without Losing Our Minds

How We Handle Complex State in Flutter Without Losing Our Minds

Flutter makes it incredibly easy to build beautiful apps fast. When you're starting out, everything feels smooth: a few screens, some setState, and things just work.

But then the app grows.

You add chat screens, pagination, animations, API calls, offline storage, and suddenly:

  • UI starts flickering
  • Pagination behaves randomly
  • Scroll position jumps
  • Images reload for no reason
  • You're scared to touch setState

We hit this exact phase while scaling our Flutter app. This blog is about what went wrong, why setState breaks at scale, and how adopting Stacked (MVVM) helped us regain control of state.

This is not a beginner tutorial. This is for Flutter developers who are building real, production-scale apps.


Flutter Is Easy to Start, Hard to Scale

Flutter's biggest strength is also its biggest trap.

You can:

  • Put logic directly in widgets
  • Call setState anywhere
  • Mix API calls, UI, and business logic

And initially, this feels productive.

But as screens become complex, this freedom turns into chaos.

At some point, we realized:

Our UI was no longer predictable.

A single action triggered multiple rebuilds. Fixing one bug created two more. Debugging became guesswork.

That's when we knew our state management approach had to change.


Why setState Breaks at Scale

setState is not evil. But unstructured setState is dangerous.

The core problem

setState rebuilds everything below it in the widget tree.

In small widgets, this is fine. In large screens, it becomes a silent performance killer.

Real problems we faced

  • Typing in a chat input caused the pet profile image to reload
  • Pagination triggered unnecessary full-screen rebuilds
  • the
  • UI state and business logic were tightly coupled

The worst part? There was no clear ownership of the state.


The Hidden Enemy: Rebuild Storms

One of the hardest issues to debug in Flutter is rebuild storms.

A rebuild storm happens when:

  • State updates are too broad
  • Widgets listen to more states than they need
  • Animations or streams trigger rebuilds every frame

Example

We had an AnimatedBuilder with an infinite animation controlling a gradient.

That animation:

  • Rebuilt the entire widget
  • Caused images to reload
  • Triggered unnecessary layout passes

The UI looked fine, but performance slowly degraded.

This is where we learned an important lesson:

If you don't control rebuild boundaries, Flutter will punish you silently.

Enter Stacked: What It Is and Why It Matters

Before diving deeper into problems, let's clarify what Stacked is.

Stacked is an MVVM (Model-View-ViewModel) architecture package for Flutter that enforces clear separation between UI and business logic. Think of it as structured state management with strong opinions about where things belong.

The architecture is simple:

  • View → Your widget, purely for rendering
  • ViewModel → Holds state and business logic
  • Service → Handles external dependencies (APIs, storage)

We chose Stacked after evaluating Provider, Riverpod, and BLoC because it gave us the clearest mental model for our growing team.


The Mental Shift: UI Should Not Own State

The breakthrough moment was realizing this:

Widgets should render state, not manage it.

In many Flutter apps, widgets:

  • Fetch data
  • Hold loading flags
  • Perform pagination
  • Handle retries
  • Manage error states

This creates tight coupling between UI and logic.

To fix this, we needed:

  • Clear ownership of state
  • Predictable rebuilds
  • Testable business logic

How Stacked Transformed Our Architecture

Before: The Messy Widget

class ChatScreen extends StatefulWidget {
  @override
  _ChatScreenState createState() => _ChatScreenState();
}

class _ChatScreenState extends State<ChatScreen> {
  bool _isLoading = false;
  List<Message> _messages = [];
  String? _error;
  
  @override
  void initState() {
    super.initState();
    _fetchMessages();
  }
  
  Future<void> _fetchMessages() async {
    setState(() => _isLoading = true);
    try {
      final messages = await chatApi.getMessages();
      setState(() {
        _messages = messages;
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _error = e.toString();
        _isLoading = false;
      });
    }
  }
  
  @override
  Widget build(BuildContext context) {
    if (_isLoading) return LoadingSpinner();
    if (_error != null) return ErrorWidget(_error!);
    return ListView.builder(...); // Plus 200 more lines
  }
}

Problems:

  • Business logic trapped in widget
  • Impossible to unit test
  • setState scattered everywhere
  • No clear error recovery flow

After: Clean MVVM

// View - Pure UI
class ChatScreenView extends StackedView<ChatScreenViewModel> {
  @override
  Widget builder(context, viewModel, child) {
    if (viewModel.isBusy) return LoadingSpinner();
    if (viewModel.hasError) return ErrorDisplay(viewModel.error);
    
    return ListView.builder(
      itemCount: viewModel.messages.length,
      itemBuilder: (context, index) => MessageTile(viewModel.messages[index]),
    );
  }
  
  @override
  ChatScreenViewModel viewModelBuilder(context) => ChatScreenViewModel();
}

// ViewModel - Pure logic
class ChatScreenViewModel extends BaseViewModel {
  final _chatService = locator<ChatService>();
  List<Message> _messages = [];
  
  List<Message> get messages => _messages;
  
  Future<void> initialize() async {
    await runBusyFuture(_fetchMessages());
  }
  
  Future<void> _fetchMessages() async {
    _messages = await _chatService.getMessages();
    notifyListeners();
  }
}

Benefits:

  • ViewModel is pure Dart (easily testable)
  • UI only reacts to state changes
  • Error handling centralized
  • Logic can be reused across platforms

This separation alone eliminated 70% of our state-related bugs and reduced our average PR debugging time from 2 hours to 20 minutes.


ViewModel Boundaries: The Most Important Rule

The biggest mistake teams make with MVVM is creating god ViewModels.

We avoided that by following one rule:

One ViewModel = One Screen

What goes into the ViewModel

  • Loading flags
  • Pagination tokens
  • Error states
  • Business decisions
  • API orchestration

What stays in the View

  • Layout
  • Styling
  • Animations
  • User interaction wiring

If a widget started to feel "smart", we moved logic into the ViewModel.


Avoiding Rebuild Storms with Stacked

Stacked gives you fine-grained rebuild control—if you use it correctly.

Key practices we follow

1. Rebuild only when needed

We use ViewModelBuilder.reactive only when the UI truly depends on state.

For static widgets:

  • We extract them
  • Make them const
  • Keep them out of reactive rebuilds

2. Split widgets aggressively

Instead of one massive widget:

  • Header widget
  • List widget
  • Footer widget
  • Input widget

Each listens only to what it needs.

3. Expose minimal state

Instead of exposing entire objects:

  • Expose booleans
  • Expose computed getters
  • Expose read-only values

This keeps rebuilds predictable.


Pagination: Where Most Flutter Apps Break

Pagination is deceptively hard.

The common mistakes:

  • Fetching multiple pages in parallel
  • Scroll jumping when new data loads
  • Showing loaders in the wrong place
  • Re-fetching already loaded data

Our pagination rules

All pagination logic lives in the ViewModel.

The ViewModel controls:

  • isFetching
  • hasMore
  • nextToken
  • Initial vs paginated loading

The View:

  • Only triggers fetchMore()
  • Never decides when to paginate

Scroll stability

We never:

  • Replace the entire list
  • Reset scroll position
  • Rebuild parent widgets unnecessarily

This made pagination boring and reliable—which is exactly what you want.


Handling Loading & Error States Cleanly

One underrated benefit of MVVM is clean state representation.

Our ViewModel exposes:

  • isBusy
  • hasError
  • errorMessage

The View simply reacts.

No try/catch blocks in UI. No duplicated loaders across widgets.

This also made unit testing trivial.


Testing Became Possible (and Easy)

Before MVVM:

  • Logic lived in widgets
  • Tests were painful
  • Bugs slipped through

After MVVM:

  • ViewModels are pure Dart
  • API calls are mocked
  • State transitions are testable

We now test:

  • Pagination edge cases
  • Error recovery
  • Loading transitions

Our test coverage went from 12% to 68% in three months. That alone justified the architecture change.


Quick Win: Extract Your First ViewModel

Pick your messiest screen. Move ALL business logic to a new ViewModel class. Keep only UI code in the widget.

You'll see the difference in 30 minutes.

Start with screens that:

  • Make API calls
  • Have complex loading states
  • Include pagination
  • Mix UI and business logic

Don't migrate everything at once. Start with new features and gradually refactor problem screens.


Common Anti-Patterns We Actively Avoid

Here are mistakes we consciously stopped making:

  • Calling APIs directly from widgets
  • Using setState for business logic
  • Listening to entire ViewModels everywhere
  • Storing mutable state in widgets
  • Triggering rebuilds from animations
  • Creating god ViewModels that manage multiple screens
  • Putting UI logic in ViewModels

Each of these caused real bugs for us at scale.


When NOT to Use Stacked

Let's be honest: every architecture has tradeoffs.

Stacked might be overkill if:

  • Your app has fewer than 10 screens
  • You're building a prototype or MVP
  • Your team is unfamiliar with MVVM patterns
  • You have simple, local state only

For small apps, setState or Provider might be perfectly fine.

But if you're planning to scale, starting with structure early pays dividends.


Stacked Won't Fix Everything

If your widgets are still 500+ lines, you have a decomposition problem, not a state management problem.

Before adding architecture:

  • Split widgets into smaller pieces
  • Extract reusable components
  • Use composition over complexity

Good architecture amplifies good design. It can't fix fundamentally messy code.


Our Architecture at a Glance

Before Stacked After Stacked
Logic scattered across 12 widgets Logic in 1 ViewModel
6 loading flags in different places 1 isBusy property
2+ hours debugging per PR 20 minutes average

What We'd Do Differently If We Started Today

Looking back, we would:

  • Introduce ViewModels earlier (after screen 5, not screen 25)
  • Keep widgets smaller from day one
  • Avoid over-engineering initially, but structure early
  • Treat state as a first-class concern
  • Write tests for ViewModels immediately

Flutter scales extremely well—but only if state is disciplined.


Final Thoughts: Flutter Scales If You Respect State

Flutter is not the problem.

Unstructured state is.

Stacked (MVVM) didn't slow us down—it saved us as the app grew:

  • Fewer regressions
  • Predictable UI
  • Happier developers
  • Better performance
  • Testable architecture

If your Flutter app is starting to feel fragile, the solution is not more hacks.

The solution is clear ownership of the state.

And for us, Stacked (MVVM) made that ownership explicit.

Read more