Widget Anti-Pattern #
After learning the correct way to build widgets in Flutter, it’s also important to understand the patterns to avoid. Anti-patterns are solutions that look correct on the surface but cause real problems — poor performance, memory leaks, excessive rebuilds, or hard-to-maintain code. This article collects the most common anti-patterns found in real Flutter codebases, complete with diagnosis and fixes.
1. Not Using const #
One of the easiest and most often ignored performance fixes. Using const for eligible widgets can reduce widget rebuilds by up to 70%. Flutter can cache and reuse the same instance instead of creating new objects on every rebuild.
// ANTI-PATTERN: widgets without const even though they could be const
class _MyScreenState extends State<MyScreen> {
int _counter = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Counter: $_counter'),
SizedBox(height: 16), // recreated on every rebuild!
Icon(Icons.star), // recreated on every rebuild!
Padding(
padding: EdgeInsets.all(16), // recreated on every rebuild!
child: Text('Static label'), // recreated on every rebuild!
),
],
);
}
}
// CORRECT: const on everything that doesn't depend on state
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Counter: $_counter'), // can't be const (runtime value)
const SizedBox(height: 16), // const -- never rebuilds
const Icon(Icons.star), // const -- never rebuilds
const Padding(
padding: EdgeInsets.all(16),
child: Text('Static label'), // const -- never rebuilds
),
],
);
}
Enable the lint rulesprefer_const_constructorsandprefer_const_literals_to_create_immutablesinanalysis_options.yaml. The Flutter DevTools Performance tab shows which widgets rebuild most often — start there.
2. Monolithic Widgets — One Widget for Everything #
Creating a huge widget that handles many responsibilities is an anti-pattern that makes maintenance, testing, and rebuild optimization difficult.
// ANTI-PATTERN: one huge widget handling everything
class ProfileScreen extends StatefulWidget { ... }
class _ProfileScreenState extends State<ProfileScreen> {
User? _user;
List<Post> _posts = [];
bool _isFollowing = false;
// ... lots of state
@override
Widget build(BuildContext context) {
// 200+ line build method handling:
// - header with profile photo
// - stats (followers, following, post count)
// - follow/unfollow button
// - photo/video grid
// - highlights/story
// All in one build() -- every setState rebuilds everything!
return Column(children: [
/* 200 lines... */
]);
}
}
// CORRECT: break into small, focused widgets
class ProfileScreen extends StatelessWidget {
final User user;
final List<Post> posts;
const ProfileScreen({super.key, required this.user, required this.posts});
@override
Widget build(BuildContext context) {
return CustomScrollView(
slivers: [
SliverToBoxAdapter(child: ProfileHeader(user: user)),
SliverToBoxAdapter(child: ProfileStats(user: user)),
SliverToBoxAdapter(child: ProfileActions(user: user)),
SliverGrid(
delegate: SliverChildBuilderDelegate(
(context, i) => PostThumbnail(post: posts[i]),
childCount: posts.length,
),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
),
),
],
);
}
}
// Each small widget only rebuilds when its data changes
class ProfileHeader extends StatelessWidget { ... } // only rebuilds when user changes
class ProfileStats extends StatelessWidget { ... } // only rebuilds when stats change
class ProfileActions extends StatefulWidget { ... } // follow/unfollow state isolated
3. Helper Functions as Widget Substitutes #
A very common anti-pattern: using plain functions to build UI parts instead of creating separate widgets. Result: content can’t be const, can’t be cached, and always rebuilds with the parent.
// ANTI-PATTERN: helper function returning a Widget
class _HomeState extends State<HomeScreen> {
int _counter = 0;
// This function is ALWAYS called again every time _counter changes
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.all(16),
color: Colors.blue,
child: const Column(
children: [
CircleAvatar(radius: 40, child: Icon(Icons.person, size: 40)),
SizedBox(height: 8),
Text('Expensive Static Header'),
],
),
);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
_buildHeader(), // called again every counter change!
Text('$_counter'),
ElevatedButton(
onPressed: () => setState(() => _counter++),
child: const Text('Increment'),
),
],
);
}
}
// CORRECT: a separate widget that can be const
class AppHeader extends StatelessWidget {
const AppHeader({super.key});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
color: Colors.blue,
child: const Column(
children: [
CircleAvatar(radius: 40, child: Icon(Icons.person, size: 40)),
SizedBox(height: 8),
Text('Expensive Static Header'),
],
),
);
}
}
class _HomeState extends State<HomeScreen> {
int _counter = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
const AppHeader(), // NEVER rebuilds even when the counter changes!
Text('$_counter'),
ElevatedButton(
onPressed: () => setState(() => _counter++),
child: const Text('Increment'),
),
],
);
}
}
4. Overly Broad setState() #
Calling setState() in a big widget causes the entire subtree to rebuild, even though only a small part of the UI changed.
// ANTI-PATTERN: setState in a big widget with many children
class _ScreenState extends State<BigScreen> {
bool _isLoading = false;
Future<void> _refresh() async {
setState(() => _isLoading = true); // rebuilds the ENTIRE BigScreen!
await fetchData();
setState(() => _isLoading = false); // rebuilds the ENTIRE BigScreen again!
}
@override
Widget build(BuildContext context) {
return Column(
children: [
const ExpensiveHeader(), // unnecessary rebuild!
const ExpensiveContent(), // unnecessary rebuild!
const ExpensiveFooter(), // unnecessary rebuild!
if (_isLoading)
const LinearProgressIndicator(), // this is what should change
],
);
}
}
// CORRECT: isolate state into the smallest widget possible
class _ScreenState extends State<BigScreen> {
Future<void> _refresh() => fetchData(); // no setState here
@override
Widget build(BuildContext context) {
return Column(
children: [
const ExpensiveHeader(), // never rebuilds
const ExpensiveContent(), // never rebuilds
const ExpensiveFooter(), // never rebuilds
LoadingButton( // only this widget rebuilds
onPressed: _refresh,
child: const Text('Refresh'),
),
],
);
}
}
// Small widget with isolated state
class LoadingButton extends StatefulWidget { ... }
class _LoadingButtonState extends State<LoadingButton> {
bool _isLoading = false;
Future<void> _handle() async {
setState(() => _isLoading = true); // only LoadingButton rebuilds
try { await widget.onPressed(); }
finally {
if (mounted) setState(() => _isLoading = false);
}
}
// ...
}
5. Memory Leaks — Not Disposing Resources #
Not calling dispose() on controllers and subscriptions is the most common cause of memory leaks in Flutter. Resources stay alive in memory even though the widget has been destroyed. The following memory lifecycle visualization shows how leaks happen:
flowchart TD
subgraph Skenario_Leak["Leak Scenario"]
WidgetDestroyed["Widget Destroyed"] -.->|"Forgot Controller.dispose"| ControllerStays["Controller Stays Alive in Memory"]
ControllerStays --> HeapLeak["RAM Load Keeps Increasing (Leak)"]
end
subgraph Skenario_Aman["Safe Scenario"]
WidgetDestroyed2["Widget Destroyed"] --->|"Calls Controller.dispose"| ControllerDestroyed["Controller Removed from Memory"]
ControllerDestroyed --> HeapClean["Clean RAM Memory"]
end// ANTI-PATTERN: resources not disposed
class _VideoPlayerState extends State<VideoPlayer> {
late AnimationController _controller;
late StreamSubscription _subscription;
late TextEditingController _textController;
late ScrollController _scrollController;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: const Duration(seconds: 1));
_subscription = dataStream.listen(_handleData);
_textController = TextEditingController();
_scrollController = ScrollController();
// FORGOT dispose -- all resources leak into memory!
}
// dispose() is missing or incomplete
@override
Widget build(BuildContext context) => Container();
}
// CORRECT: dispose ALL resources
class _VideoPlayerState extends State<VideoPlayer> {
late AnimationController _controller;
late StreamSubscription _subscription;
late TextEditingController _textController;
late ScrollController _scrollController;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: const Duration(seconds: 1));
_subscription = dataStream.listen(_handleData);
_textController = TextEditingController();
_scrollController = ScrollController();
}
@override
void dispose() {
_controller.dispose(); // AnimationController
_subscription.cancel(); // StreamSubscription
_textController.dispose(); // TextEditingController
_scrollController.dispose(); // ScrollController
super.dispose(); // ALWAYS call last
}
@override
Widget build(BuildContext context) => Container();
}
6. setState() After the Widget Is Disposed #
Calling setState() after the widget is destroyed causes the error: setState() called after dispose(). This often happens in async callbacks.
// ANTI-PATTERN: no mounted check before setState
class _DataState extends State<DataWidget> {
List<Item> _items = [];
Future<void> _loadData() async {
final data = await api.fetchItems(); // async -- takes time
// The widget may have been disposed by now!
setState(() => _items = data); // ERROR if already disposed!
}
// ...
}
// CORRECT: always check mounted before setState in async callbacks
class _DataState extends State<DataWidget> {
List<Item> _items = [];
Future<void> _loadData() async {
final data = await api.fetchItems();
if (!mounted) return; // check first!
setState(() => _items = data); // safe
}
// ...
}
7. shrinkWrap: true + NeverScrollableScrollPhysics #
This combination looks like an easy solution for placing a ListView inside a ListView, but it’s very dangerous for performance — all items are built at once without lazy loading.
// ANTI-PATTERN: shrinkWrap killing lazy loading
Column(
children: [
const HeaderWidget(),
ListView.builder(
shrinkWrap: true, // DANGEROUS for long lists
physics: const NeverScrollableScrollPhysics(), // DANGEROUS
itemCount: 10000, // all 10,000 items are built at once!
itemBuilder: (context, index) => ListTile(
title: Text('Item $index'),
),
),
const FooterWidget(),
],
)
// CORRECT: use CustomScrollView with Slivers
CustomScrollView(
slivers: [
const SliverToBoxAdapter(child: HeaderWidget()),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(title: Text('Item $index')),
childCount: 10000, // automatic lazy loading -- only visible items built
),
),
const SliverToBoxAdapter(child: FooterWidget()),
],
)
8. The Opacity Widget in Animations #
Using the Opacity widget for animation is an anti-pattern because it triggers saveLayer() — an expensive GPU operation that allocates an offscreen buffer.
// ANTI-PATTERN: Opacity inside an animation
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Opacity(
opacity: _controller.value, // saveLayer() every frame!
child: child,
);
},
child: const MyWidget(),
)
// CORRECT: use FadeTransition or AnimatedOpacity
// FadeTransition uses layer compositing -- much more efficient
FadeTransition(
opacity: _controller, // no saveLayer()
child: const MyWidget(),
)
// Or for implicit:
AnimatedOpacity(
opacity: _isVisible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: const MyWidget(),
)
Avoid the Opacity widget, and especially avoid it inside animations. Use AnimatedOpacity or FadeInImage instead. For simple opacity effects without animation, consider using a color with an alpha value instead of the Opacity widget.
9. Business Logic Inside build() #
Performing heavy computation, complex formatting, or unnecessary operations inside build() causes repeated work every time the widget rebuilds.
// ANTI-PATTERN: heavy computation in build()
@override
Widget build(BuildContext context) {
// Called on every rebuild -- an O(n²) operation!
final filtered = products
.where((p) => p.price < _maxPrice)
.toList()
..sort((a, b) => a.name.compareTo(b.name));
// Unnecessarily repeated formatting
final formattedDate = DateFormat('EEEE, dd MMMM yyyy', 'en').format(DateTime.now());
// Regex recompiled on every rebuild
final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+');
return Column(children: [...]);
}
// CORRECT: precompute outside build or cache the results
class _ProductState extends State<ProductScreen> {
List<Product> _products = [];
double _maxPrice = 1000000;
List<Product>? _cachedFiltered; // cache the filter results
// Recompute only when data or filter changes
List<Product> get _filteredProducts {
return _cachedFiltered ??= _products
.where((p) => p.price < _maxPrice)
.toList()
..sort((a, b) => a.name.compareTo(b.name));
}
void _setMaxPrice(double price) {
setState(() {
_maxPrice = price;
_cachedFiltered = null; // invalidate cache
});
}
// Create once, reuse many times -- put outside build or as const
static final _emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+');
static final _dateFormatter = DateFormat('EEEE, dd MMMM yyyy', 'en');
@override
Widget build(BuildContext context) {
// build() is now lightweight -- only assembling widgets
return ProductList(products: _filteredProducts);
}
}
10. Excessive GlobalKeys #
GlobalKey is very powerful but expensive — it disables widget tree optimizations and causes widgets to always rebuild from scratch when moved. Excessive use can become a significant bottleneck.
// ANTI-PATTERN: GlobalKeys for purposes that don't need them
class _FormState extends State<MyForm> {
// A GlobalKey for every field -- very expensive!
final _nameKey = GlobalKey<FormFieldState>();
final _emailKey = GlobalKey<FormFieldState>();
final _phoneKey = GlobalKey<FormFieldState>();
final _addressKey = GlobalKey<FormFieldState>();
@override
Widget build(BuildContext context) {
return Column(
children: [
TextFormField(key: _nameKey), // GlobalKey on every field
TextFormField(key: _emailKey),
TextFormField(key: _phoneKey),
TextFormField(key: _addressKey),
],
);
}
}
// CORRECT: one GlobalKey for the Form, controllers for field control
class _FormState extends State<MyForm> {
final _formKey = GlobalKey<FormState>(); // one GlobalKey for the Form
final _nameController = TextEditingController();
final _emailController = TextEditingController();
void _submit() {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
// process data
}
}
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey, // only one GlobalKey
child: Column(
children: [
TextFormField(controller: _nameController),
TextFormField(controller: _emailController),
],
),
);
}
}
11. Border.all() Inside build() #
Border.all() creates a new Border object every time the widget rebuilds because Border is immutable. For frequently rebuilt widgets, this creates many useless objects.
// ANTI-PATTERN: Border.all() inside build()
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey, width: 1), // new object on every rebuild!
),
child: const Text('Content'),
);
}
// CORRECT: define the border as a constant
static const _border = Border.fromBorderSide(
BorderSide(color: Colors.grey, width: 1),
);
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(border: _border), // reuse the same object
child: const Text('Content'),
);
}
12. Unnecessary StatefulWidget #
Using StatefulWidget when StatelessWidget would suffice adds complexity without benefits. The State object stays alive in memory while the widget is in the tree.
// ANTI-PATTERN: StatefulWidget with no internal state
class UserAvatar extends StatefulWidget {
final String imageUrl;
final double radius;
const UserAvatar({super.key, required this.imageUrl, this.radius = 24});
@override
State<UserAvatar> createState() => _UserAvatarState();
}
class _UserAvatarState extends State<UserAvatar> {
// No internal state at all!
@override
Widget build(BuildContext context) {
return CircleAvatar(
radius: widget.radius,
backgroundImage: NetworkImage(widget.imageUrl),
);
}
}
// CORRECT: StatelessWidget is enough
class UserAvatar extends StatelessWidget {
final String imageUrl;
final double radius;
const UserAvatar({super.key, required this.imageUrl, this.radius = 24});
@override
Widget build(BuildContext context) {
return CircleAvatar(
radius: radius,
backgroundImage: NetworkImage(imageUrl),
);
}
}
Quick Summary #
| Anti-Pattern | Impact | Solution |
|---|---|---|
Not using const | Unnecessary rebuilds | Add const everywhere possible |
| Monolithic widgets | Massive rebuilds, hard to maintain | Break into small, focused widgets |
| Helper functions | Can’t be const, always rebuild | Create separate widgets |
| setState in a big widget | Large subtree rebuilds | Isolate state into the smallest widget |
| Not disposing resources | Memory leaks | Dispose in dispose() without exception |
| Not checking mounted | Runtime errors | if (!mounted) return; before async setState |
shrinkWrap: true | All items built at once | Replace with CustomScrollView + Slivers |
Opacity in animations | saveLayer() every frame | Use FadeTransition or AnimatedOpacity |
Heavy logic in build() | Repeated computation | Precompute or cache outside build |
| Excessive GlobalKeys | Disables optimizations | Use sparingly, controllers for field control |
Border.all() in build | New object every rebuild | Define as static const |
| StatefulWidget without state | Unnecessary complexity | Use StatelessWidget |
Use the Flutter DevTools Performance tab with the “Track Widget Rebuilds” feature to identify the widgets that rebuild most often. Start fixing from the widget with the highest rebuild count — it has the biggest impact.