Ditching Poor Flutter Habits


Introduction
In Flutter development, there a few challenges that frequently surface: ensuring responsive design across devices and effectively managing resource disposal. Beginners often fall into common traps, such as over-relying on MediaQuery
for responsive layouts or defaulting to StatefulWidget
for resource management. But what if I told you there are better, smarter ways to handle these issues?
Say Goodbye to MediaQuery for Sizing
The Problem with Fractional Sizing
At first glance, MediaQuery
seems like a convenient way to scale widgets based on screen size. For instance:
Container(
width: MediaQuery.of(context).size.width * 0.3,
child: Text('Click Me!'),
)
At first, this seems practical — the container adapts to screen size. However, this approach comes with significant drawbacks:
Rigid Layouts: Simply scaling widgets often results in uninspired designs that fail to adapt meaningfully to different devices.
Missed Opportunities: Larger screens can display additional content or reorganize layouts for better usability. Fractional sizing just makes things bigger or smaller, missing the chance to optimize layouts.
A Better Approach: Constraints and Adaptable Layouts
Flutter provides layout widgets like Flexible
, Expanded
, and LayoutBuilder
to create designs that adapt dynamically to available space. For example:
Row(
children: [
Flexible(
child: Container(color: Colors.blue),
),
Flexible(
child: Container(color: Colors.red),
),
],
)
In this example, the two containers share space proportionally, adapting naturally to different screen sizes.
For more complex scenarios, LayoutBuilder
gives you control over how your UI changes based on constraints:
Widget responsiveLayout(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth > 600) {
return Row(
children: [
Expanded(child: Text('Sidebar')),
Expanded(child: Text('Main Content')),
],
);
} else {
return Column(
children: [
Text('Main Content'),
Text('Sidebar'),
],
);
}
},
);
}
This code dynamically adjusts the layout based on available width, creating a truly responsive experience.
Responsiveness Beyond Scaling
Responsive design isn’t just about resizing widgets; it’s about rethinking the user experience for each device. For example:
On a phone: Use compact designs and stacked layouts.
On a tablet: Leverage the extra space for side-by-side views.
On a desktop: Add more content or features to take advantage of the larger screen.
Simplifying Resource Management with
Disposer
The Challenges of StatefulWidget
When working with resources like TextEditingController
or FocusNode
, StatefulWidget
is often used to ensure proper disposal:
dartCopy codeclass MyStatefulWidget extends StatefulWidget {
@override
_MyStatefulWidgetState createState() => _MyStatefulWidgetState();
}
class _MyStatefulWidgetState extends State<MyStatefulWidget> {
final TextEditingController _controller = TextEditingController();
final FocusNode _focusNode = FocusNode();
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller,
focusNode: _focusNode,
);
}
}
While this works, StatefulWidget
can be verbose, less readable, and harder to manage for complex apps. It also violates principles like Single Responsibility and Separation of Concerns, as it combines UI-building logic with state management.
A Smarter Solution: The Disposer
Widget
The Disposer
widget allows you to use StatelessWidget
while still properly disposing of resources. Here’s how it works:
Create the
Disposer
WidgetDisposer extends StatefulWidget { final VoidCallback dispose; const Disposer({super.key, required this.dispose}); @override _DisposerState createState() => _DisposerState(); } class _DisposerState extends State<Disposer> { @override Widget build(BuildContext context) => const SizedBox.shrink(); @override void dispose() { widget.dispose(); super.dispose(); } }
Use It in a
StatelessWidget
DisposerExampleView extends StatelessWidget { final TextEditingController controller = TextEditingController(); final FocusNode focusNode = FocusNode(); void dispose() { controller.dispose(); focusNode.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Disposer Example')), body: Column( children: [ TextField( controller: controller, focusNode: focusNode, ), Disposer(dispose: dispose), ], ), ); } }
This setup ensures proper cleanup while keeping your code clean and readable.
Benefits of Using Disposer
Shorter Code: Eliminate the need for boilerplate-heavy
StatefulWidget
.Improved Readability: Focus on UI and logic without intertwining state management.
Reusable Logic: The
Disposer
widget can handle any resource with adispose
method, such as animation controllers or streams.
Bringing It All Together
By combining constraint-based layouts with the Disposer
widget, you can create Flutter apps that are not only more responsive but also cleaner and easier to maintain. Here’s a practical example:
ResponsiveAndCleanApp extends StatelessWidget {
final TextEditingController controller = TextEditingController();
final FocusNode focusNode = FocusNode();
void dispose() {
controller.dispose();
focusNode.dispose();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
return Scaffold(
appBar: AppBar(title: const Text('Smarter Flutter App')),
body: constraints.maxWidth > 600
? Row(
children: [
Expanded(child: TextField(controller: controller)),
Expanded(child: Text('Tablet Layout')),
],
)
: Column(
children: [
TextField(controller: controller),
Text('Mobile Layout'),
],
),
);
},
);
}
}
This app adjusts its layout based on screen size while using the Disposer
widget for resource management.
Avoid Embedding Too Much Logic in Widgets and Overloading the Build Method
One of the most common pitfalls in Flutter development is overloading the build
method with too much logic or embedding non-UI-related logic directly in widgets. This approach can make the code harder to read, maintain, and debug while also leading to unnecessary widget rebuilds and performance issues.
Why It's Bad:
Violates Separation of Concerns:
Widgets are primarily responsible for building the UI, not managing business logic.
Mixing logic and UI tightly couples the two, making it difficult to refactor or test.
Decreases Readability:
- Overloaded
build
methods become cluttered with conditional logic, loops, and state handling, making it harder for others (or your future self) to understand the code.
- Overloaded
Causes Unnecessary Rebuilds:
- When a widget rebuilds, all embedded logic gets reevaluated, even if it doesn't need to change.
What to Do Instead:
Separate Business Logic:
Extract non-UI-related logic into dedicated classes, controllers, or state management solutions such as
Provider
,Bloc
, orRiverpod
.Example: Instead of fetching data directly in the widget, use a state management approach to handle the logic and pass the state to the widget.
Break Down Widgets:
Divide complex widgets into smaller, reusable components. This not only makes the codebase cleaner but also limits the scope of rebuilds.
Example: If a widget has a list and a button, separate the list into its own widget and the button into another.
Use Stateless Logic Helpers:
- For reusable logic or calculations, create helper functions or utility classes that can be called without being tied to the widget lifecycle.
Memoize Computations:
- If the logic in the
build
method involves expensive calculations, consider memoizing results using techniques likeValueListenableBuilder
orSelector
to avoid unnecessary recomputation.
- If the logic in the
Example: From Bad to Better
Bad Practice (Overloaded Build Method):
class ProductList extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Fetching data directly in the build method
final products = fetchProductsFromApi();
return ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
// Business logic embedded in UI
final discountedPrice = products[index].price * 0.9;
return ListTile(
title: Text(products[index].name),
subtitle: Text('Price: \$${discountedPrice.toStringAsFixed(2)}'),
);
},
);
}
}
Better Practice (Separated Logic):
class ProductList extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Pass data/state via Provider or another state management solution
final products = context.watch<ProductProvider>().products;
return ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
return ProductTile(product: products[index]);
},
);
}
}
class ProductTile extends StatelessWidget {
final Product product;
const ProductTile({required this.product});
@override
Widget build(BuildContext context) {
final discountedPrice = product.price * 0.9;
return ListTile(
title: Text(product.name),
subtitle: Text('Price: \$${discountedPrice.toStringAsFixed(2)}'),
);
}
}
Benefits:
The
ProductList
widget now only manages the layout and delegates business logic to theProductProvider
andProductTile
.The code is cleaner, reusable, and easier to test.
Changes to business logic (e.g., how discounts are calculated) can be made without touching the UI code.
By avoiding overloaded build
methods and separating logic, your Flutter code will be more efficient, maintainable, and easier to debug.
Other Common Poor Habits
Here are some common poor habits in Flutter development that developers should consider ditching for better performance, maintainability, and user experience:
Rebuilding Entire Widgets Unnecessarily
Decreases app performance, especially when rebuilding expensive widgets unnecessarily.
Instead, use const constructors where possible and memoize values to avoid unnecessary builds. Use ValueNotifier or ChangeNotifier for granular updates.
Not Leveraging Flutter's Built-In Widgets
Reinventing the wheel leads to wasted effort and potential bugs.
Instead, use built-in widgets like ListView, GridView, AnimatedBuilder, and others before creating custom solutions.
Hardcoding Text Styles and Colors
Results in inconsistent design and difficulty in updating themes.
Instead, use the ThemeData system and TextTheme for consistency across the app.
Ignoring Null Safety
Increases the risk of runtime crashes due to null exceptions.
Instead, embrace Dart's null safety by using nullable types and non-nullable types appropriately.
Overcomplicating Animations
Don’t write complex, hard-to-maintain animation code manually.
Instead, use Flutter’s animation APIs like AnimatedContainer, TweenAnimationBuilder, or Flare for simpler implementations.
Not Optimizing Image and Asset Loading
Leads to increased memory usage and slow app performance.
Instead, use AssetImage with Image.asset or CachedNetworkImage for efficient image loading and caching.
Failing to Use Asynchronous Programming Properly
Blocking the UI thread with heavy tasks leads to poor responsiveness.
Instead: Use Future, async/await, or Isolate for time-consuming operations.
Not Handling Edge Cases
Results in unhandled errors or poor user experience.
Instead, use error handling with try-catch, FutureBuilder, and StreamBuilder to manage async errors gracefully.
Using Magic Numbers
Reduces readability and makes updates harder.
Instead, define constants or use SizedBox and Padding widgets for spacing.
Conclusion: Build Better Flutter Apps
Flutter's power lies in its flexibility, but with great power comes the responsibility to use it wisely. By ditching poor habits like over-relying on MediaQuery
, overloading the build
method, and defaulting to verbose approaches like StatefulWidget
for resource management, you can create apps that are not just functional but elegant, scalable, and efficient.
This journey begins by embracing best practices:
Use constraint-based layouts and widgets like
Flexible
,Expanded
, andLayoutBuilder
to craft truly responsive designs that enhance the user experience across devices.Simplify resource management with smart solutions like the
Disposer
widget, making your code more concise and maintainable.Follow separation of concerns by extracting business logic into dedicated classes or state management solutions, ensuring that your UI remains clean and focused.
Leverage Flutter’s built-in widgets and embrace null safety, asynchronous programming, and consistent theming for better performance and reliability.
By addressing these common challenges and replacing bad habits with smarter approaches, you’ll not only improve the quality of your code but also ensure a smoother development process and a more satisfying user experience. Flutter development is a journey, and every step towards better practices contributes to building apps that truly shine. So, let’s ditch those bad habits and start creating smarter Flutter apps today!
Subscribe to my newsletter
Read articles from Siro Daves directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Siro Daves
Siro Daves
Software engineer and a Technical Writer, Best at Flutter mobile app development, full stack development with Mern. Other areas are like Android, Kotlin, .Net and Qt