Ditching Poor Flutter Habits

Siro DavesSiro Daves
9 min read

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?

  1. 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.

  1. 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:

  1. Create the Disposer Widget

     Disposer 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();
       }
     }
    
  2. 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 a dispose 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.


  1. 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:

  1. 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.

  2. 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.
  3. Causes Unnecessary Rebuilds:

    • When a widget rebuilds, all embedded logic gets reevaluated, even if it doesn't need to change.

What to Do Instead:

  1. Separate Business Logic:

    • Extract non-UI-related logic into dedicated classes, controllers, or state management solutions such as Provider, Bloc, or Riverpod.

    • 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.

  2. 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.

  3. 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.
  4. Memoize Computations:

    • If the logic in the build method involves expensive calculations, consider memoizing results using techniques like ValueListenableBuilder or Selector to avoid unnecessary recomputation.

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 the ProductProvider and ProductTile.

  • 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:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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.

  9. 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, and LayoutBuilder 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!

10
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