Skip to content
ExponaBLOG
FLUTTER · DAY 100 / 100

100 days of Flutter: lessons — best practices explained

Wrapping up the series.

Expona Admin6 min read

100 days of Flutter: lessons — best practices explained

After shipping Flutter code to production across multiple platforms and iterating with real users, certain patterns emerge that tutorials skip over. We've learned that the real battle isn't finding the right package—it's building the right architecture and understanding how constraints shape everything else. Here's what 100 days taught us.

Layout is the foundation — Understanding constraints solved most UI bugs

Most Flutter developers encounter the dreaded "RenderFlex overflowed by X pixels" error. When this happens, the instinct is often to wrap widgets in Expanded, Flexible, or SingleChildScrollView without thinking. That's treating the symptom. The real issue is almost always a misunderstanding of how constraints flow down the widget tree.

Constraints in Flutter are simple in theory: a parent tells a child "you can be this big, at most." The child then positions itself within those bounds and tells its parent how big it actually needs to be. But in practice, nested Row and Column widgets combined with ListView, GridView, and custom layouts create scenarios where constraints propagate in ways that feel magical until they break.

We spent a week on a checkout screen where the payment button would disappear on certain device orientations. The layout was roughly:

dart
Column(
  children: [
    Expanded(child: ListView(...)), // Product list
    // Payment button hidden here
    PaymentButton(),
  ],
);

The Expanded widget told the ListView to take all remaining space after its siblings. Since PaymentButton came after, it had zero height left. Moving the button into a SizedBox with explicit height solved it, but the real lesson was: constraints always win. You can't tell a widget to be big if its parent has already constrained it to be small.

Understanding constraints means:

  • Know what each layout widget does: Row and Column take up all available space by default. ListView takes infinite height/width. SizedBox and Container with explicit dimensions override parent constraints.
  • Debug visually: Flutter's debugPaintSizeEnabled = true and the DevTools layout inspector show constraint boundaries. Use them early.
  • Avoid defensive wrapping: Adding Expanded everywhere "just in case" masks the real constraint problem and makes the tree harder to reason about.

Once we internalized this, UI bugs dropped significantly. Layout issues that used to take hours of trial-and-error now took minutes of constraint analysis.

Architecture beats packages — Clean layers mattered more than the state library

Early on, we chased state management solutions. We tried Provider, Riverpod, GetX, Bloc—each one promising to solve our problems. What we learned: the state library matters far less than how you structure your code around it.

We shipped a ride-hailing feature with a clean three-layer architecture:

  • Presentation layer: Widgets, view models, UI state.
  • Domain layer: Business logic, entities, use cases.
  • Data layer: Repositories, API clients, local storage.

Each layer had clear responsibilities. The presentation layer never spoke directly to the API. The domain layer didn't know about Flutter or HTTP. This meant we could swap the state library, change the API client, or replace local storage without touching business logic.

Here's what that looked like in practice:

dart
// Domain layer: pure Dart, no Flutter dependency
class RideUseCase {
  final RideRepository repository;

  Future<Ride> requestRide(Location pickup, Location dropoff) {
    return repository.createRide(pickup, dropoff);
  }
}

// Data layer: handles API details
class RideRepository {
  final RideApiClient apiClient;

  Future<Ride> createRide(Location pickup, Location dropoff) async {
    final response = await apiClient.post('/rides', {
      'pickup': pickup.toJson(),
      'dropoff': dropoff.toJson(),
    });
    return Ride.fromJson(response);
  }
}

// Presentation layer: manages UI state
class RideViewModel extends ChangeNotifier {
  final RideUseCase useCase;
  RideState state = RideState.idle();

  Future<void> onRequestRide(Location pickup, Location dropoff) async {
    state = RideState.loading();
    notifyListeners();
    try {
      final ride = await useCase.requestRide(pickup, dropoff);
      state = RideState.success(ride);
    } catch (e) {
      state = RideState.error(e.toString());
    }
    notifyListeners();
  }
}

This structure meant that when we needed to add offline caching, we only modified the repository. When we added analytics, we wrapped the use case. When we switched from Provider to Riverpod, the domain and data layers didn't change at all.

The mistake we see often is coupling business logic to the state library. Bloc actions calling API endpoints directly. Riverpod providers with Firebase imports. That works until it doesn't—then you're stuck refactoring everything. A clean architecture is slower to set up but faster to maintain and iterate on.

Test on real devices — Emulators hide performance and platform issues

We built a feature that felt buttery smooth on the Android emulator and iOS simulator. Ship day came. Real users reported janky scrolling. On investigation, the emulator was running on a modern desktop with gigabytes of RAM. Real devices were 3–4 year old phones with significantly less memory and processing power.

The same issue appeared on both platforms, but only showed up when we tested with:

  • Real device hardware
  • Real network conditions (not localhost)
  • Real data volumes (not mock datasets)
  • Background processes running

Emulators lie about performance. They also lie about platform-specific behavior. We built a feature using GetStorageService that worked perfectly in the simulator but behaved differently on Android when the OS was low on memory and killed background processes.

Real device testing caught:

  • Jank: Scrolling through large lists revealed rendering performance issues the emulator masked.
  • Memory leaks: Long-running features would crash on real devices but not in the simulator.
  • Platform differences: iOS handled keyboard overlays differently than Android. Only clear when testing both devices side by side.
  • Network behavior: Slow connections (3G, poor WiFi) exposed timeout issues we hadn't configured for.
  • Battery and thermal: Heavy processing that drained battery fast was invisible until users complained.

Our workflow shifted: emulator for rapid iteration, real devices for any feature touching performance, networking, or platform-specific APIs. We kept a few test devices on hand and used Firebase Test Lab for additional real-device coverage before releases.

Ship and iterate — Store releases teach more than tutorials

We could have read articles about app store policies, release processes, and crash reporting. Instead, we shipped. That first release taught us more in one week than a month of reading could.

Shipping exposed real problems:

  • Crash reporting: Firebase Crashlytics showed us errors we'd never seen locally. A null pointer in a specific code path that only triggered under exact conditions.
  • Performance monitoring: Real user monitoring revealed which screens were slow, which API calls timed out, which devices struggled.
  • User behavior: Analytics showed features we thought were essential weren't being used. Others we built as afterthoughts drove engagement.
  • Release process friction: The first Android release took four attempts to pass Play Store review. Each failure taught us something about app store requirements.

We learned to ship small, often, and early. A minimal feature in the store teaches more than weeks of polish in development. Real users with real devices, real networks, and real usage patterns will find issues no amount of testing can predict.

Common mistakes

After watching teams struggle, a few patterns stand out:

  • Ignoring constraints early: New developers add layout wrappers without understanding why. This debt compounds fast. Spend time learning constraints before you have a 2000-line widget tree.
  • Building around the wrong state library: Don't ask "which state library should we use?" Ask "what's our architecture?" The library is an implementation detail. If you can't swap it, your architecture isn't clean enough.
  • Relying on emulator performance: A smooth 60 FPS in the simulator might be 25 FPS on a mid-range device. Test on real hardware before declaring victory.
  • Waiting for perfection: Feature-complete isn't store-ready. Store-ready is what matters. Iterate from there.

Key takeaways

  • Constraints are the foundation of Flutter layout. Understanding them prevents hours of debugging.
  • Architecture layers matter more than the specific packages you choose. Clean separation means easy refactoring.
  • Real device testing surfaces performance and platform issues emulators hide. Build it into your workflow early.
  • Shipping to production teaches more than tutorials. Small, frequent releases compound learning and reveal real user problems.
  • Flutter is a productivity multiplier when the fundamentals are solid. Invest in understanding constraints, architecture, and testing from day one.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles