Migration to Flutter Guide
Discover our battle-tested 21-step framework for a smooth and successful migration to Flutter!

12 Flutter & Dart Code Hacks & Best Practices – How to Write Better Code?

Agnieszka Forajter - Flutter Developer at LeanCode
Agnieszka Forajter - Flutter Developer at LeanCode
Oct 27, 2025 • 10 min
Agnieszka Forajter - Flutter Developer at LeanCode
Agnieszka Forajter
Flutter Developer at LeanCode

We all have our favorite Flutter and Dart tips and tricks. Maybe it's a clever use of extension methods or discovering that pattern matching eliminates an entire helper class. These little “aha!” moments don't always come from big architectural decisions. Sometimes they're just about knowing the right Dart feature at the right time.

In this article, we’re sharing LeanCode’s 12 practical Flutter and Dart patterns (or as we like to call them, Lean Flutter & Dart Hacks) that help you write less boilerplate, make your code cleaner, and catch mistakes earlier. Apply these patterns and you'll find yourself coding faster, communicating more clearly with your teammates, and spending less time debugging issues that the compiler could have caught.

Some of the hacks focus on readability. Others lean on the type system to prevent bugs before they happen. A few are simply about using the language features that are already there - quietly waiting to make your life easier.

You might not agree with every single tip - and that's fine. These aren't hard rules, just conversation starters about what "lean" code really means in our Flutter projects. Hope you'll find them helpful! Keep on reading.

Check Null in Conditions

If you've been writing Dart for a while, you've probably used the ! operator more times than you'd like to admit. And chances are, you've also dealt with the runtime crashes that come from forgetting about one during a refactor.

The traditional null check pattern has a fundamental problem:

if (userData != null) {
  sendEvent(userData!.name);
}

There's a disconnect between checking for null and using the value. You verify it's not null in the condition, then you have to assert it again with ! in the body. This gap is where bugs creep in - refactor your code, move things around, and suddenly that `!` is pointing at something that might actually be null.

This matters especially for class fields. If userData is a local variable, the != check is sufficient - Dart's flow analysis promotes it to non-nullable inside the if block, so no ! is needed. But with class fields, Dart can't guarantee the field won't change between the null check and usage (another method could modify it, for instance), so you're forced to use the ! operator even after checking for null.

Pattern matching solves this by combining the null check and value binding in one step:

if (userData case final user?) {
  sendEvent(user.name);
}

The null check and destructuring happen together. You get a non-nullable user binding that's compiler-verified, no assertions needed.

If you want, you can go deeper and extract properties from inside the class:

if (userData case UserData(:final name?)) {
  sendEvent(name);
}

Use Declarative List Literals

We all know well the imperative way of building lists - creating an empty list and then calling .add() or .addAll() repeatedly. Looking at the code, it’s pretty hard to read:

final output = <Message>[];
output.add(welcomeMessage);
if (optionalMessage != null) {
  output.add(optionalMessage!);
}
if (encryptMessages) {
  output.addAll(messages.map((m) => EncryptedMessage(m)));
} else {
  output.addAll(messages);
}

There's a lot of noise here. Multiple statements, conditional blocks, and that ! operator appearing again. 

Dart's collection literals support control-flow and spread operators, letting you build lists the same way you build widget trees - declaratively:

final output = [
  welcomeMessage,
  ?optionalMessage,
  if (encryptMessages)
    for (final m in messages) EncryptedMessage(m)
  else
    ...messages,
];

The structure of the list is immediately visible because everything happens in one expression. After using this for a while, the imperative approach starts to feel like extra work for no reason.

Pattern Matching with switch

Nested if/else blocks are one of those things that start simple and grow into something hard to follow. You add one condition, then another, and now you're looking at a spaghetti of conditions that's difficult to read and even harder to modify.

// IF/ELSE BLOCKS
if (user is Admin) {
  return AdminPage();
} else if (user is User && user.verified) {
  return HomePage();
} else {
  return WelcomePage();
}

Switch expressions with pattern matching let you express this more naturally:

// SWITCH WITH PATTERN MATCHING
return switch (user) {
  Admin() => AdminPage(),
  User(verified: true) => HomePage(),
  _ => WelcomePage(),
};

Each case stands on its own; pattern matching does the heavy lifting - checking types, destructuring properties, all in one clean expression. There's no nesting, no repeated variable names, just a clear mapping from condition to result.

You get exhaustiveness checking from the compiler, and the structure mirrors how you think about the problem: "given this input, which case matches?"

Unpack Quickly with Destructuring

Before Dart 3, unpacking data often meant writing a handful of small, repetitive lines - one for each value you needed. Dart 3 destructuring lets you unpack multiple fields or elements in a single line, making your code cleaner and easier to read when working with coordinates, sizes, or other tuple-like data.

final point = Point(x: 4, y: 5);
// Old way:
final x = point.x;
final y = point.y;


// With destructuring:
final Point(:x, :y) = point;

You can use it with records:

final coordinates = (4, 5);
final (x, y) = coordinates;

Or with lists, using the rest pattern to grab just the parts you need:

final pointList = [4, 5, 6, 7];
final [x, y, ...] = pointList;

Now you can do things like:

print('coordinates: ($x, $y)'); // "coordinates: (4, 5)"

It’s one of those features that instantly feels right. It makes everyday Dart code just a bit more expressive - and a lot more joyful to write.

Avoid Redundant async/await

A common pattern in Flutter apps is writing functions that simply pass through a Future from another source - calling a repository method, forwarding an API call, or wrapping a service. We often add async and await to these functions out of habit, even when they're not actually needed.

Here's what that typically looks like:

Future<User> getUser() async {
  return await repository.getUserDetails();
}

This works, but the async and await aren't doing anything useful here. The function isn't handling the result, transforming it, or catching errors. It's just returning the Future that getUserDetails() already provides.

You can simplify this by removing the keywords entirely:

Future<User> getUser() {
  return repository.getUserDetails();
}

Even better, use an expression body:

Future<User> getUser() => repository.getUserDetails();

Use async / await when you're actually doing something with the result - awaiting multiple operations in sequence, handling errors with try-catch, or transforming the data before returning it.

// Handling errors
Future<User> getUser() async {
  try {
    return await repository.getUserDetails();
  } catch (err) {
    logger.error('Failed to fetch user', err);
    rethrow;
  }
}

// Transforming data
Future<String> getUserName() async {
  final user = await repository.getUserDetails();
  return user.name.toUpperCase();
}

Save async / await for when you actually need to manipulate the result; for simple pass-throughs, just return the Future directly.

Explain Code Analysis Ignores

You're working on something, a lint warning appears, and you know it's fine to ignore in this specific case. So you drop in an // ignore: comment and move on. The warning disappears, the code works, and you forget about it.

The problem comes later. Someone else reads your code, or you come back to it months down the line, and there's this ignored lint rule with no explanation. Was it intentional? Is there a good reason? Or did someone just want the warning to go away? Without context, it's impossible to tell.

// ignore: use_design_system_colors
color:Colors.black

Add a short explanation, and the intent becomes clear:

// Solid black color required - doesn't depend on the theme
// ignore: use_design_system_colors
color:Colors.black

Now, anyone reading this understands the decision. It's not just silencing a warning - it's a documented exception with reasoning behind it. This prevents situations where ignored rules pile up and nobody knows which are justified and which should be fixed.

Write More Readable Test Expectations

Your tests should tell a story - not look like cryptic puzzles. Instead of writing bare comparisons, Dart’s expressive matchers (isEmpty, throwsA, isA, startsWith, etc.) make your test code read like plain English. This makes it easier for future readers to understand exactly what’s being verified.

// INSTEAD OF:
expect(list, []);
expect(result is MyClass, true);
expect('Hello world'.startsWith('Hello'), true);
expect(await myFutureFunction(), 42);

// USE:
expect(list, isEmpty);
expect(result, isA<MyClass>());
expect('Hello world', startsWith('Hello'));
awaitexpectLater(myFutureFunction(),completion(42));

expect(
  () => controller.run(),
  throwsA(isA<MyException>()),
);

Readable tests are self-documenting, making them easier to update and fix when needed.

Drop Unnecessary Dependencies in Tests

When writing tests, it's tempting to use the same custom components you use throughout your app - design system widgets, custom extensions, utility classes. The problem is that every dependency you pull into a test is another potential point of failure.

Custom widgets often rely on themes, providers, localization, or other context that needs to be set up correctly. When a test fails, you want it to fail because what you're testing is broken - not because of unrelated dependencies. Fragile tests can block your entire team's CI/CD pipeline, preventing deployments and stalling other developers' work.

The opposite problem is just as bad: custom dependencies can hide real bugs. If your custom widget handles errors gracefully or provides fallback, your test might pass even when the actual functionality is broken.

Here's a common example:

testWidgets('Body of AppScaffold is visible', (tester) async {
  final testBody = AppText('Hello world');
  await tester.pumpWidget(AppScaffold(body: testBody));
  expect(find.text('Hello world'), findsOneWidget);
});

This test checks whether AppScaffold displays its body correctly. But by using AppText, you're also testing whether your custom text widget works, the theme is configured properly, and all its dependencies are available. That's a lot of surface area for a test that's supposed to be about the scaffold.

Use Flutter's built-in widgets instead - they're well-known and reliable:

testWidgets('Body of AppScaffold is visible', (tester) async {
  final testBody = Text('Hello world');
  await tester.pumpWidget(AppScaffold(body: testBody));
  expect(find.text('Hello world'), findsOneWidget);
});

If this test fails, you know it's because of AppScaffold. Keep your tests simple and focused - it will make them easier to maintain.

Let Your Logger Do Its Job

When you're logging something that includes an error or a stack trace, it's easy to just drop them into the message string and move on. It’s fine, but we can do better - most logging libraries actually have dedicated fields for those values.

// Instead of:
logger.warning('Something went wrong! $error $stackTrace');

// Use:
logger.warning('Somethingwentwrong!', error, stackTrace);

It's a small change, but it makes a difference in how your logs look and behave. In the console, you get properly formatted stack traces instead of a wall of escaped newlines. In error-tracking tools, errors and stack traces are recognized as structured data, enabling better grouping, search-ability, and filtering. 

This one-line adjustment gives you cleaner output and more context when you're investigating an issue.

Prefix Your Slivers to Avoid Mix-ups

If you've worked with Flutter's sliver APIs, you've probably run into this at least once: you pass a sliver widget into a regular Column or Row, and you see the red screen at runtime. The frustrating part is that the analyzer doesn't catch it.

Slivers need to live within specific scroll views (like CustomScrollView or NestedScrollView). But when you name your custom widget DashboardAppBar, there's nothing indicating it returns a Sliver:

class DashboardAppBar extends StatelessWidget {
  const DashboardAppBar();
  @override
  Widget build(BuildContext context) {
    return SliverAppBar(...);
  }
}

Someone reading your code - or even you, six months later - might assume this can go anywhere a widget can go. A simple naming convention solves this. Prefix any widget that returns a sliver with Sliver:

class SliverDashboardAppBar extends StatelessWidget {
  const SliverDashboardAppBar();
  @override
  Widget build(BuildContext context) {
    return SliverAppBar(...);
  }
}

Our leancode_lint package includes a rule for this: prefix_widgets_returning_slivers. Let your linter catch this kind of mistake early - that's what it's for.

Replace Single-Purpose Containers with Dedicated Widgets

We all reach for Container by reflex - need a background color? Container. Need padding? Container. Need to set a size? Container. But that versatility comes with a cost. Container does a lot behind the scenes, and when you're only using it for one simple thing, you're adding unnecessary complexity.

Flutter provides smaller, focused widgets for the single-purpose cases. Using eg., DecoratedBox, Padding, ColoredBox, or SizedBox makes your intent immediately clear and keeps your widget tree lean. As a bonus, these dedicated widgets have const constructors. Container doesn't have it, so you lose the optimization when you use it unnecessarily.

// Before:
return Container(padding: EdgeInsets.all(24), child: ...);
return Container(color: Colors.white, child: ...);
return Container(decoration: ..., child: ...);
return Container(alignment: Alignment.center, child: ...);
return Container(width: ..., height: ..., child: ...);

// After:
return Padding(padding: EdgeInsets.all(24), child: ...);
return ColoredBox(color: Colors.white, child: ...);
return DecoratedBox(decoration: ..., child: ...);
return Center(child: ...);
return SizedBox(width: ..., height: ..., child: ...);

But don't take this too far. If you're combining multiple properties - padding with a background color, alignment with decoration, etc. - stick with Container. Using dedicated widgets for everything can actually make your code worse:

// DON'T
return ColoredBox(
  color: Colors.white,
  child: Padding(
    padding: EdgeInsets.all(16),
    child: ...,
  ),
);

// DO
return Container(
  color: Colors.white,
  padding: EdgeInsets.all(16),
  child: ...,
);

One important note: DecoratedBox + Padding is not the same as Container. Container accounts for decoration insets (like border width) when calculating inner padding. If you have a border and padding, Container will adjust the padding to account for the border width, especially with BoxDecoration.border aligned to the center or inside. The dedicated widgets won't do that - they just do their one thing and nothing more.

Use dedicated widgets when they make your code clearer. Use Container when you need its flexibility. The Flutter analyzer plugin and leancode_lint package include rules (like use_padding, use_colored_box) to help you catch these patterns automatically.

Try Dot Shorthand Feature (Beta)

Writing Flutter widgets means typing out a lot of fully-qualified names. EdgeInsets.all(), MainAxisSize.min, Brightness.dark - the type is already known from context, but that's how the syntax works.

Dart 3.10 introduces dot shorthand, a feature that lets you skip redundant type names when the context makes them obvious. Swift and Kotlin developers will recognize this immediately - it's one of those features that, once you've used it, you really miss when it's not there. Your code does exactly the same thing, just with less noise.

Here's a typical Flutter widget in the traditional style:

AppScaffold(
  padding: const EdgeInsets.all(16),
  header: const AppHeader(
    icon: AppIcon.home,
    title: 'Dashboard page',
  ),
  backgroundColor: switch (brightness) {
    Brightness.dark => AppColor.black,
    Brightness.light => AppColor.white,
  },
  body: Column(
    mainAxisSize: MainAxisSize.min,
    children: [],
  ),
);

With dot shorthand, the same code becomes:

AppScaffold(
  padding: const .all(16),
  header: const .new(
    icon: .home,
    title: 'Dashboard page',
  ),
  backgroundColor: switch (brightness) {
    .dark => .black,
    .light => .white,
  },
  body: Column(
    mainAxisSize: .min,
    children: [],
  ),
);

In switch expressions, where you're already pattern-matching on a type, the repetition disappears entirely.

This feature is still in beta as of now, but it's shaping up to be a significant improvement. Less repetition means less visual clutter - your code's structure becomes easier to scan at a glance.

Summing Up Our Flutter & Dart Code Hacks

That was 12 tips and tricks that might seem minor on their own - but they add up to clearer, more expressive, and safer code. These best practices stem from our daily work at LeanCode on applications across various industries and scales.

Feel free to expand or disagree on any of these ideas - that’s what makes the Flutter community so great. We’ll share part two with more tips in the future, so stay tuned and keep writing beautiful Dart code!

You can also follow LeanCode on LinkedIn or X for more useful Flutter content.

Special thanks to Kamil Sztandur who along with Agnieszka Forajter was collaborating on the Lean Flutter Hacks series. They also received valuable support and feedback from the rest of the LeanCode team.

Rate this article
Star 1Star 2Star 3Star 4Star 5
5.00 / 5 Based on 2 reviews

You may also like

Migrate to the new Dart Analyzer Plugin System - LeanCode

Lint Smarter, Not Harder: Migrate to the New Dart Analyzer Plugin System

The new first-party Dart analyzer plugin system is a significant step forward for Flutter developers, expected to replace custom_lint by integrating custom rules directly into the standard `dart analyze` command. See how to migrate to the new Dart Analyzer Plugin - the LeanCode way.

Widgetbook Entries Generator by Leancode

How We Boosted Moving Flutter Widgets to Widgetbook

At LeanCode, while working on complex Flutter projects, we noticed that integrating widgets into Widgetbook was often repetitive and time-consuming. To address this, we created the Widgetbook Entries Generator - a VSCode extension that simplifies the process. See how it works.

Get Ready for Edge-to-Edge: Designing Flutter Apps

Mastering Edge-To-Edge in Flutter: A Deep Dive Into the System Navigation Bar in Android

Starting with Android 15, edge-to-edge becomes the default - bringing a modern, immersive feel to apps. In this article, our Flutter developer shows how to handle system bars in Flutter across Android versions and prepare your UI for Android 16, where edge-to-edge will be mandatory.

A replacement for freezed in Dart

No Macros in Dart, How to Replace Freezed?

Unfortunately, the Dart team has taken up the difficult decision of abandoning their work on the macros language feature. Although freezed is a powerful package, it comes with some costs. At some point, we decided to move forward and use alternative solutions for new code and projects. See our approach.