38 Dart & Flutter Tips That Actually Make a Difference in Production
After 4+ years of building Flutter applications, I’ve learned that the biggest improvements often come from small decisions repeated every day. A better way to handle a nullable value. A cleaner approach to lists. Knowing when compute() actually makes sense. Catching memory leaks instead of guessing about them. Keeping business logic where it belongs and mutch more. I collected these lessons from code reviews, production bugs, experiments, reading lots of articles & other devs code and things I’ve learned the hard way. This isn’t another list of generic “Flutter best practices.” For each tip, I’ll focus on what it does, why it matters, and when you should actually use it. Part 1: Modern Dart Language Features 1. Stop reaching for ! after a null check // Before if (userData != null) { sendEvent(userData!.name); } // After if (userData case final user?) { sendEvent(user.name); } The real problem with != null checks is that they're a snapshot in time, nothing more. If userData is a class field rather than a local variable, the analyzer can't guarantee its value hasn't changed between the check and the use - especially across an async gap or with a computed getter. We hit a real production crash after a "harmless" refactor turned a final field into a computed getter and silently broke that assumption. Pattern matching ties the check and the binding into a single step, so this class of bug simply can't happen. 2. Build your lists declaratively final output = [ welcomeMessage, ?optionalMessage, if (encrypt) ...messages.map(encrypt) else ...messages, ]; Instead of chained .add() and .addAll() calls. The real payoff shows up in code review: the entire structure of the list is visible in one expression, instead of a reviewer having to trace mutations across six imperative lines. 3. Use switch with pattern matching instead of nested if/else return switch (user) { Admin() => AdminPage(), User(verified: true) => HomePage(), _ => WelcomePage(), }; The real value here is exhaustiveness checking. When you add a new case months later - a new enum value, a new subtype - the compiler forces you to handle it immediately, instead of finding out from a support ticket that someone hit the unhandled path in production. 4. Use destructuring to unpack values in one step final Point(:x, :y) = point; final (x, y) = coordinates; final [x, y, ...] = pointList; Especially useful with records when handling composite data coming back from an API - it saves time and eliminates the ordering mistakes that happen when you unpack values manually, one variable at a time. 5. async/await aren't always necessary // Before Future getUser() async { return await repo.getUserDetails(); } // After Future getUser() => repo.getUserDetails(); The rule: only drop them if the function isn't doing any real work with the result. If you need try/catch or need to transform the result, keep them - removing them in that case costs you the ability to catch errors locally. 6. Explain why you're ignoring a lint warning // This color is intentionally static and doesn't follow the theme // ignore: use_design_system_colors color: Colors.black With 4 developers on my team, this one small rule has saved a lot of repeated back-and-forth in review. An ignore with no explanation just means a different reviewer asks the same question next time. Part 2: Testing You Can Actually Trust 7. Write assertions that read like a sentence expect(list, isEmpty); expect(result, isA ()); expect(() => run(), throwsA(isA ())); The difference between a clear matcher and a vague assertion shows up months later, when a test fails and you've long forgotten the context: one minute to understand what broke, versus ten minutes of investigation. 8. Don't drag unnecessary dependencies into your tests // Avoid pumpWidget(AppScaffold(body: AppText('Hi'))); // Prefer pumpWidget(AppScaffold(body: Text('Hi'))); If your custom widget (AppText , say) silently swallows errors, a test can pass even when the real logic underneath is broken. We fell into this trap for real - and it cost us time tracking down a "passing test for code that didn't work." 9. Golden tests for catching visual regressions Golden tests (matchesGoldenFile ) capture a reference image of a widget and diff it against a fresh render on every CI run. The payoff: you catch a visual regression - shifted padding, a color that changed by accident - before it ever reaches human review, let alone production. The cost: you need to pin them down carefully, because font rendering differences across CI environments produce false positives. We only run them inside one standardized CI image to avoid that. 10. Use mocktail over mockito for new projects, and fake_async for time-dependent tests mocktail skips code generation entirely, which saves real build time on larger projects. fake_async lets you test code involving Future.delayed or Timer without actually waiting in real time - faster, deterministic tests, instead of a real await Future.delayed() that slows down CI and occasionally produces flaky results. Part 3: Debugging & Naming 11. Let your logger do its job // Before logger.warning('$error $stackTrace'); // After logger.warning('message', error, stackTrace); The difference shows up in tools like Sentry: a properly structured stack trace lets these tools group similar errors automatically, instead of leaving you to search through a wall of text buried in one message. 12. Name Sliver widgets clearly // Confusing - silently returns a Sliver class DashboardAppBar extends StatelessWidget { ... } // Clear class SliverDashboardAppBar extends StatelessWidget { ... } The analyzer won't warn you about this one - you only find out at runtime, usually with a red screen far away from where the widget was actually defined. Clear naming here is real protection, not just tidiness. 13. Use dedicated widgets instead of single-purpose Container s Container is incredibly flexible, which is exactly why it's easy to reach for it even when you only need one simple behavior. For a single responsibility, prefer the widget that communicates that responsibility directly: // Instead of Container( padding: const EdgeInsets.all(24), child: child, ); // Prefer Padding( padding: const EdgeInsets.all(24), child: child, ); The same idea applies to common cases: ColoredBox(color: Colors.white, child: child); DecoratedBox(decoration: decoration, child: child); Center(child: child); SizedBox(width: 100, height: 100, child: child); The benefit isn't just a smaller widget. The code tells the reader exactly what it is doing. Don't take this too far, though. When you're combining several responsibilities, Container can be clearer: Container( color: Colors.white, padding: const EdgeInsets.all(16), child: child, ); Use the dedicated widget when it makes the intent clearer; use Container when its flexibility genuinely helps. 14. Use dot shorthand when the type is already obvious Modern Dart can remove repetitive type names when the surrounding context already tells the compiler what type is expected. Instead of: padding: const EdgeInsets.all(16), mainAxisSize: MainAxisSize.min, brightness: Brightness.dark, you can write: padding: const .all(16), mainAxisSize: .min, brightness: .dark, This is particularly useful in Flutter widget trees and switch expressions, where the expected type is already clear. It's a small readability improvement, but it reduces visual noise without changing the behavior of the code. Part 4: Performance - Beyond the Basics 15. Stop worrying about shader jank - but know why If you've been carrying around --cache-sksl shader warm-up routines from an older project, you can retire them. Impeller - Flutter's rewritten rendering engine - compiles shaders ahead-of-time at build time instead of the first time they're used at runtime, which is what caused that old "stutters once, then runs smooth forever" bug. Impeller is now the default rendering engine across the current stable line: default on iOS since Flutter 3.16, and on Android since Flutter 3.22. If you're still hand-rolling shader warm-up code, it's dead weight. The advanced move here isn't a hack - it's knowing the old hack is obsolete and trusting the default. 16. Use long-lived isolates for repeated work, not compute() in a loop compute() (built on Isolate.run ) is convenient, but it spins up and tears down a fresh isolate every single call - if you're doing the same kind of computation repeatedly, that spawn-and-copy overhead adds up, and you'll get better throughput from an isolate that stays alive and receives multiple messages over time via SendPort /ReceivePort . This is the pattern usually called a "background worker" isolate. Reach for it when you're processing a stream of items (image thumbnails, incoming socket messages) rather than one one-off blob of JSON. // One-off: fine final result = await compute(parseJson, jsonString); // Repeated: spin up once, reuse final worker = await Worker.spawn(); // keeps a SendPort/ReceivePort alive for (final item in incomingStream) { worker.send(item); } If an isolate is finishing with a large object to hand back (a decoded image buffer, say), use Isolate.exit() instead of a normal return - it transfers ownership of the object to the receiving isolate instead of copying it, which matters a lot for large payloads. 17. Reach for a custom RenderObject only when layout widgets genuinely can't do it This is a legitimate escape hatch, not a toy. The honest criteria: you need it when existing layout widgets like Row, Column, Stack, or Wrap can't express the layout you need, when you need fully custom painting like a chart or a game element, when you need custom hit-testing logic, or when you need to skip the Widget/Element overhead entirely for performance-critical rendering. If none of those apply, you don't need it - CustomPaint combined with a CustomMultiChildLayout covers most "custom layout" needs without touching the rendering pipeline directly. 18. --obfuscate --split-d
Comments
No comments yet. Start the discussion.