

Rating: 4.84 / 5 Based on 25 reviews
Rating: 4.84 / 5 Based on 25 reviews
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 24 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.
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);
}! 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);
}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,
];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(),
};You get exhaustiveness checking from the compiler, and the structure mirrors how you think about the problem: "given this input, which case matches?"
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;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;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.
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();
}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();
}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();
}async / await for when you actually need to manipulate the result; for simple pass-throughs, just return the Future directly.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.blackAdd 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.blackNow, 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.
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.
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);
});AppScaffold. Keep your tests simple and focused - it will make them easier to maintain.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('Something went wrong!', error, stackTrace);This one-line adjustment gives you cleaner output and more context when you're investigating an issue.
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.
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.
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: [],
),
);Less repetition means less visual clutter - your code's structure becomes easier to scan at a glance.
The build() method can be called multiple times during a widget's lifetime. Placing computations inside it means they will run on every rebuild, leading to dropped frames and a less responsive UI.
Instead, perform the work only when it's actually needed - for example, in initState(), didUpdateWidget(), or inside your state management layer. Keep build() focused on describing the UI, not preparing the data.
In cases where heavy computation cannot simply be moved out of the build method but must instead be offloaded entirely, using compute() is the next logical step.
// ❌ BAD: Heavy computation in build() method
// Runs on every rebuild!
@override
Widget build(BuildContext context) {
final data = expensiveOperation(widget.inputData);
return DataChart(data: data);
}
// ✅ GOOD: Heavy computation outside of build()
@override
void initState() {
super.initState();
processedData = expensiveOperation(widget.inputData);
}
@override
void didUpdateWidget(MyWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.inputData != oldWidget.inputData) {
processedData = expensiveOperation(widget.inputData);
}
}
@override
Widget build(BuildContext context) {
return DataChart(data: processedData);
}Heavy operations, such as complex data processing or parsing large datasets, can block the main isolate and make your app appear frozen. Running this work with compute() executes it in a separate isolate, allowing the UI to remain smooth and responsive.
Keep in mind that compute() isn't a silver bullet. Data passed to another isolate must be copied, which introduces overhead. For smaller tasks, that overhead may outweigh the performance benefits, so it's best suited for genuinely expensive computations.
The example below demonstrates both approaches. In a real application, this kind of logic would typically live outside the UI layer - the widget is used here only to illustrate the difference.
int calculateFibonacci(int n) {
if (n <= 1) {
return n;
}
return calculateFibonacci(n - 1) + calculateFibonacci(n - 2);
}
AppButton(
text: 'Run',
onPressed: () async {
// ❌ Will cause UI freeze (runs in main isolate)
final result = calculateFibonacci(50);
// ✅ Won't cause UI freeze (runs in different isolate)
final isolatedResult = await compute(calculateFibonacci, 50);
},
);Ternary operators are perfect for simple conditions, but readability quickly suffers once multiple branches or additional conditions are introduced. Deeply nested ternaries are difficult to scan, harder to debug, and more error-prone during future changes.
When the logic becomes more complex, extracting it into if statements or using a switch expression results in code that's easier to read and maintain.
// ❌ Overloaded ternary operator
return isBlocked
? BlockedWidget()
: status == .foo1
? Foo1Widget()
: status == .foo2 && data != null
? Foo2Widget(data)
: status == .foo3
? Foo3Widget()
: LoadingWidget();
// ✅ Complex conditions handled with better ways
if (isBlocked) {
return BlockedWidget();
}
return switch (status) {
.foo1 => Foo1Widget(),
.foo2 when data != null => Foo2Widget(data),
.foo3 => Foo3Widget(),
_ => LoadingWidget(),
};Flutter encourages building your UI from small, reusable widget classes rather than methods that return widgets.
Widgets created by helper methods become part of the parent widget's build() implementation, so they're rebuilt whenever the parent rebuilds. Extracting a subtree into a dedicated widget class gives Flutter more opportunities to optimize rebuilds, improves reusability, and makes the widget hierarchy easier to inspect in Flutter DevTools.
As an added benefit, widget classes have a clear public interface, are easier to test, and can be reused across multiple screens. If you prefer to keep it private, simply prefix the class name with an underscore.
// ❌ Don't use widget-returning methods
Widget buildHeader() => Padding(
padding: const EdgeInsets.all(16),
child: Text('Hello world!', style: AppTextStyle.header),
);
// ✅ Declare a dedicated widget class instead
class Header extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Text('Hello world!', style: AppTextStyle.header),
);
}
}If you've worked with Kotlin, you're probably already familiar with let(). The same idea can be implemented in Dart with a small extension that makes it easier to write more expressive transformation chains.
It works particularly well for nullable values, allowing you to transform objects without introducing temporary variables or repetitive null checks. It's also a convenient way to map a value to another type while keeping the code's flow easy to follow.
// Copy-paste this extension into your workspace 💡
extension Let<T extends Object> on T {
R let<R>(R Function(T) f) => f(this);
}
// ❌ Unnecessarily long
final amount = AppAPI().getAmountFor(id);
String? formattedAmount;
if (amount != null) {
formattedAmount = AppFormatters
.currency(amount)
.toUpperCase();
}
return formattedAmount == null
? null
: PaymentLog(formattedAmount);
// ✅ Simple and short with .let() extension
return AppAPI()
.getAmountFor(id)
?.let(AppFormatters.currency)
.toUpperCase()
.let(PaymentLog.new);Many issues - such as style inconsistencies, common mistakes, design system misuse, or potential bugs - can be detected automatically while you're writing code, long before a pull request is opened.
A well-configured static analysis setup provides immediate feedback, reduces repetitive review comments, and helps keep the codebase consistent across the entire team. Many lint rules also offer automatic fixes through Dart Quick Fixes, making it easy to apply best practices with just a few clicks.
The following setup provides a solid starting point for most Flutter projects.
dev_dependencies:
# Enhanced lint rules based on `flutter_lints`, extended with custom rules and
# quick fixes by LeanCode team, targeting issues like enforcing proper Design
# System adaptation. Requires additional `custom_lint` dependency to run but
# will be migrated to Dart analysis server plugin soon.
leancode_lints: ^x.y.z
# Recommended baseline lints for Flutter apps if `leancode_lints` can't be used
flutter_lints: ^x.y.z
# Must-have if you're using Riverpod. Helps avoid subtle and poorly documented
# pitfalls.
riverpod_lint: ^x.y.zSometimes you have to build lists from a few different parts, and it can quickly get out of hand. You have a couple of fixed elements, a fragment that's sometimes there, and another that depends on a boolean flag. Before you know it, you are doing arithmetic on lists using the + operator.
Here's a real example of assembling HTTP headers for an API request:
Dart
// INSTEAD OF:
final data = api.fetchProfile(
body: GetProfileRequestBody(),
headers: AppHttpHeader.defaultHeaders +
[AppHttpHeader.accountId(accountId), AppHttpHeader.userId(userId)] +
(AppHttpHeader.optionalHeaders(config) ?? []) +
AppHttpHeader.serviceHeaders +
(productId != null ? [AppHttpHeader.productId(productId)] : []),
);Look at what the + operator forces on you. Every fragment has to be a list, so single values get wrapped in throwaway [...] brackets. A nullable fragment needs a verbose ?? [] fallback. A conditional element becomes a ternary operator that evaluates to either a one-element list or an empty one. The intent is completely buried under all the plumbing.
The spread operator (...) lets you compose the list declaratively, exactly how you already build widget trees:
Dart
// USE:
final data = api.fetchProfile(
body: GetProfileRequestBody(),
headers: [
...AppHttpHeader.defaultHeaders,
AppHttpHeader.accountId(accountId),
AppHttpHeader.userId(userId),
...?AppHttpHeader.optionalHeaders(config),
...AppHttpHeader.serviceHeaders,
if (productId != null) AppHttpHeader.productId(productId),
],
);Now the code structure mirrors the data structure itself. Single values sit as single values, and the ordering is obvious, top to bottom. The null-aware spread ...? silently skips a null fragment, eliminating the fallback. And Dart's if effortlessly replaces the clunky ternary operator.
While this avoids allocating a few intermediate lists under the hood, this isn't really a performance hack. The real win is readability and intent, which is exactly why it’s recommended to use in Flutter.
Ever opened a widget and found values like 1.4, 3, or 16 scattered around the build method? The code works, but you are left guessing. What does 1.4 mean? Why 1.4 and not 1.5? Will anyone remember the reasoning three months from now?
Dart
// INSTEAD OF:
@override
Widget build(BuildContext context) {
final scaleFactor = MediaQuery.textScalerOf(context).scale(fontSize);
return Text(
text,
maxLines: scaleFactor > 1.4 ? 1 : 3,
);
}The problem here isn't just aesthetic. These numbers encode decisions, and left as bare literals, those decisions are invisible. In the example above, 1.4 isn't a random threshold; it's a strict business rule for accessibility: "Once the user's text scaling passes this point, collapse to a single line so the layout doesn't break."
Give those numbers names, and the decision becomes part of the code:
Dart
// USE:
static const _accessibilityScaleThreshold = 1.4;
static const _largeScaleMaxLines = 1;
static const _defaultMaxLines = 3;
@override
Widget build(BuildContext context) {
final scaleFactor = MediaQuery.textScalerOf(context).scale(fontSize);
return Text(
text,
maxLines: scaleFactor > _accessibilityScaleThreshold
? _largeScaleMaxLines
: _defaultMaxLines,
);
}
Working with dynamic data like JSON maps or platform channels inevitably leads to casting. The standard as operator does the job right up until the type is wrong, at which point it throws a TypeError and crashes your flow.
A tiny helper extension can turn that hard failure into a graceful null:
Dart
extension CastOrNull on dynamic {
T? castOrNull<T>() => this is T ? this as T : null;
}
// INSTEAD OF:
// Throws a TypeError if 'count' comes back as a String
final count = data['count'] as int?;
// USE:
// Safely returns null instead of throwing
final count = data['count'].castOrNull<int>();is check and as cast. But here is where we have to be honest, castOrNull doesn't make an unsafe cast safe, but it makes it silent. With as, a wrong type is a loud exception you can catch, report to your logger and investigate. With castOrNull, that same wrong type quietly becomes null and flows onward. You lose the ability to tell the difference between "the field was legitimately absent" and "the backend sent us a String where we expected an int, and we have a critical bug."Therefore, use castOrNull only when invalid data is genuinely equivalent to missing data in your business logic. If a malformed value and an absent value should follow the exact same fallback path, collapsing them into null is a clean, deliberate choice. If they shouldn't, validate the data explicitly. Knowing which situation you are in is the real hack here.
"Composition over inheritance" is an age-old rule that developers often ignore because inheritance simply feels more natural in object-oriented languages. However, Dart 3 gave us Extension Types, which is a compile-time-only wrapper that makes composition incredibly ergonomic with zero runtime cost.
Extension types let you present a distinct API during development while, at runtime, remaining nothing more than the underlying value. There is no wrapper object, no extra allocation, and no boxing. Think of its methods and getters as behaving like extension methods bolted onto that single underlying value.
Here is how you can wrap a raw JSON Map to give it a typed, domain-specific face:
Dart
final messagePayload = {
'sender': 'Alice',
'text': 'Hello world!',
'timestamp': 1710000000,
};
// USE:extension type Message(Map<String, dynamic> _payload)
// "implements" lets a Message be used anywhere a Map is expected
implements Map<String, dynamic> {
String get sender => _payload['sender'] as String;
String get text => _payload['text'] as String;
int get timestamp => _payload['timestamp'] as int;
bool get isLong => text.length > 100;
}
void onMessageReceived(Map<String, dynamic> payload) {
final message = Message(payload);
print(message.text); // "Hello world!"
}implements Map<String, dynamic>. It is what lets you pass a Message to any function expecting a Map, and it allows you to still read message['sender'] directly using the standard map subscript operator if you want to. Without it, the extension type is fully opaque and only exposes exactly what you declare.Notice there are no instance fields beyond the single representation object (_payload). Because an extension type resolves directly to its underlying value during compilation, it can't carry extra fields, only methods. This constraint is the flip side of being genuinely zero-cost, making it perfect for parsing JSONs or quickly typing dynamic data payloads.
Because extension types have a sharp edge: their "type" is a compile-time fiction. At runtime, there is no distinct Message object, but just a Map<String, dynamic>.
If your project uses code generation tools like freezed, json_serializable, or custom routers, you know the frustrating chore. You change a file, jump to the terminal, run the build command, wait, and go back to your editor. Do it a dozen times a day and it completely breaks your flow.
Bash
# INSTEAD OF:
# Manually rebuilding every time you change a file
dart run build_runner build
# USE:
# Let it watch your filesystem and rebuild automatically
dart run build_runner watch
The watch command starts build_runner in the background and regenerates outputs whenever a relevant file changes. You edit, you save, and the generated code is simply up to date by the time you look for it. No jumping to the terminal while coding, no forgetting to rebuild. You can keep your flow.
Remember that build_runner is incremental in both “watch” and “build” modes. The true benefit of watch is not the speed, but the ergonomics of not having to manually run build each time.
Also, if you've been habitually tacking on --delete-conflicting-outputs for years, you can likely drop it. Recent versions of build_runner detect and clear conflicting outputs on their own, leaving you with less ceremony, but the same result.
Managing local widget state like a TextEditingController, an AnimationController, or a scroll listener requires a lot of boilerplate in Flutter. You declare it in initState, wire it up, and remember to tear it down in dispose. The setup and cleanup for a single concern are scattered across three different methods.
The flutter_hooks package targets exactly this pain point by plugging into the widget lifecycle under the hood.
Dart
// USE:
@override
Widget build(BuildContext context) {
// Creates the controller and handles disposal automatically
final searchController = useTextEditingController();
// Watch instead of read to ensure reactivity
final cubit = context.watch<SearchCubit>();
useEffect(() {
void listener() => cubit.updateSearchQuery(searchController.text);
searchController.addListener(listener);
// The return function acts as our automatic dispose()
return () => searchController.removeListener(listener);
}, [searchController, cubit]);
return Column(
children: [
TextField(controller: searchController),
// ...
],
);
}useTextEditingController, the initState and dispose pair collapses into a single line. The useEffect hook attaches the listener and returns its own cleanup, keeping the subscription and unsubscription side by side.If you plan to adopt hooks, keep your hooks atomic. Powerful built-in hooks like useState, useEffect, or useMemoized are ready to go, but you can also build your custom ones to reuse logic across widgets. Powerful built-in hooks like useState, useEffect, or useMemoized are ready to go, and you can also build custom ones to reuse logic across widgets. The biggest mistake developers make is cramming several unrelated concerns into a single useEffect.
If a widget becomes a tangled mess of ten effects, it is just as hard to maintain as the StatefulWidget boilerplate it replaced. Give each hook one responsibility, and extract reusable logic into named custom hooks (e.g., useSearchQuery).
Those were 24 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.
Good code isn't about blindly collecting patterns; it's about applying them with intent. Feel free to expand on these or push back – that's what makes the Flutter community so great.
Feel free to expand on or disagree with any of these ideas - that’s what makes the Flutter community so great.
You can also follow LeanCode on LinkedIn or X for more useful Flutter content.
Special thanks to Kamil Sztandur, who, along with Agnieszka Forajter, collaborated on the Lean Flutter Hacks series. They also received valuable support and feedback from the rest of the LeanCode team.

FLUTTER / FLUTTER DEVELOPMENT / FLUTTER SPOTLIGHT
8 minMay 6, 2025The 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.

FLUTTER / FLUTTER ENTERPRISE
12 min.May 4, 2026Explore proven app migration strategies to Flutter and learn how to choose the right approach for your product. From minimizing risk to maximizing efficiency, this guide breaks down key paths, trade-offs, and decisions behind a successful transition.

FLUTTER / FLUTTER DEVELOPMENT / FLUTTER SPOTLIGHT
15 minJul 14, 2025Starting 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.

FLUTTER / FLUTTER DEVELOPMENT / FLUTTER SPOTLIGHT
5 minJan 29, 2025Unfortunately, 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.