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
setStateanywhere - 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:
isFetchinghasMorenextToken- 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:
isBusyhasErrorerrorMessage
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
setStatefor 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.