15 Common Mistakes in Flutter and Dart Development (and How to Avoid Them)
As Flutter projects grow, mistakes that didn’t matter early on can start affecting performance, stability, and maintainability. Some are easy to miss. Others are misunderstood or handled inconsistently across teams.
In this article, I am going to focus on mistakes that are common in Flutter and Dart apps, especially in larger codebases or long-running apps which I have often seen in the last 6-7 years of being in the field. They range from memory leaks and rebuild issues to state misuse, testing gaps, and architectural problems. All of them are avoidable. Most can be detected with static analysis such as DCM. Some require deeper architectural decisions.
Let's get started and see which of these issues are familiar to you and which of them you already have done! I bet you can resonate with many of the following issues.
Memory Leaks from Missed Disposal
This is perhaps, the most frequent issue that I have seen and continue to see still in many projects.
Some classes in Flutter allocate resources that Dart’s garbage collector doesn’t automatically clean up. These include AnimationController
, TextEditingController
, ScrollController
, FocusNode
, and StreamSubscription
. If these aren’t disposed manually, they remain in memory even after the widget that created them is gone.
This usually happens inside StatefulWidget
classes. The resource is initialized in initState
, used in the widget tree, but never cleaned up in dispose
. Over time, this leads to retained objects, increased memory usage, and—in long-lived or heavily navigated apps—crashes.
I already have written more about memory leaks in Dart, how these leaks happen, and how Dart manages memory under the hood, covered in this earlier article.
Here’s a minimal example of a common leak:
class SearchBox extends StatefulWidget {
const SearchBox({super.key});
State<SearchBox> createState() => _SearchBoxState();
}
class _SearchBoxState extends State<SearchBox> {
final controller = TextEditingController();
Widget build(BuildContext context) {
return TextField(controller: controller);
}
}
This works fine visually. But every time this widget is rebuilt and a new instance is created, the previous TextEditingController
stays in memory unless explicitly disposed.
Luckily, DCM flags this with the dispose-fields
rule. It checks for any fields in State
classes that implement dispose()
, cancel()
or close()
(like controllers) but aren’t released (and for non-Flutter code there is dispose-class-fields
). In this case, the rule will suggest fixing the leak like this:
class SearchBox extends StatefulWidget {
const SearchBox({super.key});
State<SearchBox> createState() => _SearchBoxState();
}
class _SearchBoxState extends State<SearchBox> {
final controller = TextEditingController();
void dispose() {
controller.dispose(); // correctly disposed
super.dispose();
}
Widget build(BuildContext context) {
return TextField(controller: controller);
}
}
The same mistake shows up with subscriptions:
class LiveUpdates extends StatefulWidget {
const LiveUpdates({super.key});
State<LiveUpdates> createState() => _LiveUpdatesState();
}
class _LiveUpdatesState extends State<LiveUpdates> {
late final StreamSubscription sub;
void initState() {
super.initState();
sub = getUpdates().listen((event) {
// update UI
});
}
Widget build(BuildContext context) {
return const Placeholder();
}
}
This compiles, runs, and looks correct. But the stream keeps sending events after the widget is removed. This leads to silent memory leaks and, in some cases, exceptions when those events try to update disposed state.
You can also use DCM's rule avoid-unassigned-stream-subscriptions where warns when a stream subscription is not assigned to a variable.
The fix is the same, clean up in dispose
.
void dispose() {
sub.cancel(); // Cancel is handled correctly
super.dispose();
}
In some cases, listeners are added manually using addListener()
or addPostFrameCallback()
, but never removed. This is harder to catch visually, but DCM covers it with the always-remove-listener
rule. Here's a minimal example:
class SearchBar extends StatefulWidget {
const SearchBar({super.key});
State<SearchBar> createState() => _SearchBarState();
}
class _SearchBarState extends State<SearchBar> {
final controller = TextEditingController();
void initState() {
super.initState();
controller.addListener(_onTextChange); // ❌ LINT: This listener is not removed,
}
void _onTextChange() {
// handle input
}
void dispose() {
controller.removeListener(_onTextChange); // correctly handled
controller.dispose(); // correctly handled
super.dispose();
}
Widget build(BuildContext context) {
return TextField(controller: controller);
}
}
In small projects, missing a dispose
call may not be noticeable. But in larger apps, especially those that rely on real-time data or animations, these small omissions accumulate.


While you may detect them by PR review or second eye, having a static analysis like DCM with proper rules defined can help to detect them as early as possible and reduce the cost of development. By doing this you are shifting the cost of memory leak bugs to left to development phase.
I have already written about this concept in this article, Improving Code Reviews - Tools and Practices for Dart and Flutter Projects.
Here is a quick "dev checklist" in case you want to add to your documentation to double check for each PR.
- Are all controllers and subscriptions disposed?
- Are added listeners removed?
- Are you disposing resources where they were created?
- Are you calling
super.dispose()
at the end?
If you are also curious, DCM's rule proper-super-calls checks that super calls in the initState
and dispose
methods are called in the correct order. It's fairly important because calling these super methods in the wrong order can lead to bugs when some properties are not yet initialized or have already been disposed.
Unnecessary Rebuilds via setState
setState()
is one of Flutter's most essential methods which is marking a widget as needing a rebuild. But misusing it often leads to poor performance issues, unnecessary widget rebuilds, or even runtime errors. I have seen many times issues related to this method therefore, I am going to write most common patterns that I have seen and how you can prevent it.


Let's start with example below:
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key});
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
final messages = <String>[];
void addMessage(String msg) {
setState(() {
messages.add(msg); // ❌ LINT: causes full widget rebuild
});
}
Widget build(BuildContext context) {
return Column(
children: [
HeaderWidget(), // Rebuilt unnecessarily
MessageList(messages: messages),
MessageInput(onSend: addMessage),
],
);
}
}
This works but each new message rebuilds the entire screen, including HeaderWidget
and MessageInput
, even though they didn’t change. This isn’t incorrect behavior, but it’s inefficient and adds up over time.
One way to reduce unnecessary rebuilds is to isolate the dynamic part into its own widget:
class ChatScreen extends StatelessWidget {
const ChatScreen({super.key});
Widget build(BuildContext context) {
return Column(
children: const [
HeaderWidget(),
Expanded(child: MessageListContainer()),
MessageInput(),
],
);
}
}
class MessageListContainer extends StatefulWidget {
const MessageListContainer({super.key});
State<MessageListContainer> createState() => _MessageListContainerState();
}
class _MessageListContainerState extends State<MessageListContainer> {
final messages = <String>[];
void addMessage(String msg) {
setState(() {
messages.add(msg);
});
}
Widget build(BuildContext context) {
return MessageList(
messages: messages,
onSend: addMessage,
);
}
}
Now, only MessageListContainer
rebuilds when messages change and not the whole screen.
A frequent mistake is calling setState()
within lifecycle methods like initState
, didUpdateWidget
, or directly inside the build
method itself. This triggers redundant rebuilds:
Take this as an example:
class _MyWidgetState extends State<MyWidget> {
String myString = '';
void initState() {
super.initState();
// ❌ LINT: unnecessary call, state can be set directly
setState(() {
myString = 'Hello';
});
}
Widget build(BuildContext context) {
// ❌ LINT: unnecessary call, causes extra rebuild
setState(() {
myString = 'Hello';
});
return Text(myString);
}
}
Calling setState
in such cases will result in additional widget re-rendering, which will negatively affect performance.
The avoid-unnecessary-setstate
rule detects these problematic patterns and suggests directly assigning state values without calling setState()
in these lifecycle methods:
class _MyWidgetState extends State<MyWidget> {
String myString = '';
void initState() {
super.initState();
myString = 'Hello'; // ✅ directly set without setState
}
Widget build(BuildContext context) {
return Text(myString);
}
}
Another subtle but common issue occurs when calling setState()
after an asynchronous call (await
). By the time the async operation completes, the widget might be already unmounted. This leads to runtime errors:
Take the following example:
onPressed: () async {
final data = await fetchData();
// ❌ LINT: calling setState without checking mounted
setState(() {
message = data;
});
},
The use-setstate-synchronously
rule catches these risky scenarios. The recommended fixes are either checking if the widget is still mounted or restructuring state management altogether, such as using FutureBuilder
:
One way to fix the issue above is to use mounted
:
onPressed: () async {
final data = await fetchData();
if (mounted) { // ✅ safely updating state
setState(() {
message = data;
});
}
},
Or alternatively you can use FutureBuilder
:
class _MyWidgetState extends State<MyWidget> {
Future<String>? messageFuture;
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
setState(() {
messageFuture = fetchData();
});
},
child: FutureBuilder<String>(
future: messageFuture,
builder: (context, snapshot) {
return Text(snapshot.data ?? 'Loading...');
},
),
);
}
}
Using an empty setState()
callback is typically a bug, as it triggers a rebuild without changing state, for example:
FooWidget(
onChange: (value) async {
// ❌ LINT: empty setState is unnecessary
setState(() {});
},
);
DCM's avoid-empty-setstate
rule flags these no-op rebuilds and recommends either updating state meaningfully or removing the unnecessary call entirely and ensure you are fixing this issue:
FooWidget(
onChange: (value) async {
setState(() {
myState = 'changed'; // ✅ actual state change
});
},
);
While we build apps, there are multiple libraries or other built-in mechanism that you can use as your state management solutions. For example:
- ValueNotifier & ValueListenableBuilder:
final counter = ValueNotifier<int>(0);
ValueListenableBuilder<int>(
valueListenable: counter,
builder: (context, value, _) => Text('Count: $value'),
);
- State Management Libraries (Riverpod, Bloc, Provider):
These provide controlled, decoupled updates and avoid widespread rebuilds across the widget tree.


Misusing setState()
might seem trivial at first, but as your application scales, these seemingly small inefficiencies accumulate, hurting performance and responsiveness.
In case of using libraries such as Riverpod, Bloc and Provider, DCM also provides rules that helps you ensure utilizing these libraries with their best practices.
Deep Widget Trees & Excessive Rebuilds
Flutter encourages building UIs through widget composition. Most widgets are lightweight, and nesting them is generally cheap. But in larger apps, deep or unbalanced widget trees can become a performance concern especially when paired with unnecessary rebuilds.
As soon as each time build()
is called, Flutter walks the tree, lays it out, and paints it. In deeply nested structures, that walk takes longer and touches more elements. This becomes noticeable when updates are frequent—such as during animations, gestures, or scrolls.
Here’s a basic example of a tree that’s more complex than it needs to be:
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Column(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: const [
Icon(Icons.star),
SizedBox(width: 8),
Text('Nested content'),
],
),
),
],
),
),
],
),
);
}
This layout is valid but more verbose than necessary. The nesting adds cost during rebuilds and can make the UI harder to maintain. Flattening the structure helps both performance and readability:
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: const [
Icon(Icons.star),
SizedBox(width: 8),
Text('Nested content'),
],
),
);
}
One of the aspect of this issue is architectural decision in your team, however, another aspect is not having a proper guidelines. That's where you can leverage DCM widget analyze command to help you maintain your widgets in the highest standard and quality.
Another common mistake is wrapping everything in a Container
even when it’s not needed. Container
is flexible, but it doesn’t optimize for specific layout intent. In many cases, Padding
, Align
, or DecoratedBox
are more efficient.
DCM helps identify these inefficiencies with rules like:
prefer-align-over-container
: suggests usingAlign
when only alignment is needed.prefer-transform-over-container
: flags use ofContainer
for transforms.prefer-const-border-radius
: encourages use of const values to reduce rebuild cost.prefer-declaring-const-constructor
: promotes immutable widget trees.prefer-container
: suggests replacing a sequence of widgets with a singleContainer
widget.
Here is an exmaple:
// ❌ Lint: uses Container for alignment
Container(
alignment: Alignment.center,
child: Text('Centered'),
);
that can be better if you do:
Align(
alignment: Alignment.center,
child: const Text('Centered'),
);
Reducing rebuild impact isn’t only about structure but it’s also about immutability. Marking widgets as const
tells Flutter they don’t need to be rebuilt.
const Text('Static label');
DCM’s prefer-declaring-const-constructor
rule enforces this across widgets and classes, helping shrink the diff Flutter needs to compare during the build phase.
Another optimization is using RepaintBoundary
to isolate parts of the UI that update frequently. This limits how much of the tree is re-painted when state changes.
RepaintBoundary(
child: CustomChartWidget(data: chartData),
)
This is especially useful for dashboards, scrollable lists, or widgets with high visual complexity.
For long lists, using ListView.builder
or GridView.builder
is critical. These widgets lazily build only visible items, avoiding upfront layout and memory usage.
You can read more about RepaintBoundary in my previous blog.
Finally, DCM’s avoid-returning-widgets
rule detects functions that return Widget
instances inline. This is often a sign of bloated build methods or repeated layout logic that could be extracted into a separate widget.
Let's take this small example:
Widget _buildLabel() {
return Padding(
padding: const EdgeInsets.all(8),
child: Text(title),
);
}
This potentially can be a widget as follows:
class Label extends StatelessWidget {
final String title;
const Label(this.title, {super.key});
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: Text(title),
);
}
}
This keeps the build method clean and gives Flutter more control over caching and identity management. I have a deep dive explanation about this in my book FlutterEngineering.
Poor Async Handling
Flutter apps depend heavily on asynchronous operations like network calls, file I/O, and data streaming. While Dart's Future
and Stream
APIs simplify async handling, they also introduce pitfalls if misused.


I usually see common patterns that I can categorize into failing to properly handle errors, updating widget states incorrectly, and mismanaging async calls. Let's explore.
I have mentioned this earlier in this article, let me repeat it again. A typical scenario occurs when calling setState()
after an async operation without verifying whether the widget is still active:
Future<void> loadProfile() async {
final user = await fetchUser();
setState(() {
name = user.name; // ❌ LINT: widget might be unmounted
});
}
If the widget disposes before fetchUser()
completes, it throws a runtime error: setState() called after dispose()
.
As I mentioned in setState
section, DCM’s use-setstate-synchronously
rule flags such issues including using mounted
and FutureBuilder
as I stated earlier.
Another subtle issue occurs when an awaited future throws an error after an async gap, causing unexpected exceptions:
Future<void> asyncFunctionAssignFuture() async {
try {
final future = asyncFunctionError();
await Future.delayed(const Duration(seconds: 1));
await future; // ❌ LINT: potential uncaught error
} catch (e) {
print('caught: $e');
}
}
DCM's avoid-uncaught-future-errors
rule detects such problematic patterns. The recommended solution is to handle errors directly when assigning the future:
Future<void> asyncFunctionAssignFuture() async {
try {
final future = asyncFunctionError().catchError((e) {
print('caught early: $e'); // ✅ handle potential errors immediately
});
await Future.delayed(const Duration(seconds: 1));
await future;
} catch (e) {
print('caught later: $e');
}
}
Returning a Future
directly without awaiting within a try/catch
block can silently bypass error handling:
Future<String> report(Iterable<String> records) async {
try {
return anotherAsyncMethod(); // ❌ LINT: should await
} catch (e) {
print(e);
return 'default';
}
}
DCM’s prefer-return-await
rule recommends explicitly awaiting futures to ensure errors are caught properly:
Future<String> report(Iterable<String> records) async {
try {
return await anotherAsyncMethod(); // ✅ proper error handling
} catch (e) {
print(e);
return 'default';
}
}
Calling async functions in synchronous contexts (like constructors or synchronous methods) results in untracked executions and missed exceptions:
class SomeClass {
SomeClass() {
asyncValue(); // ❌ LINT: async call in sync constructor
}
}
DCM's avoid-async-call-in-sync-function
rule recommends either explicitly ignoring or awaiting such calls:
class SomeClass {
SomeClass() {
unawaited(asyncValue()); // ✅ explicitly unawaited
}
Future<void> asyncInit() async {
await asyncValue(); // ✅ correct async method
}
}
You see that using structured async patterns, explicit error handling, and leveraging DCM rules ensures more predictable, readable, and stable asynchronous code.
DCM is a code quality tool that helps your team move faster by reducing the time spent on code reviews, finding tricky bugs, identifying complex code, and unifying code style.
Poor Images Optimization
I have seen many apps that they are huge in terms of bundle size, just because images are not optimized! This is often overlooked.
Images are essential in creating visually appealing Flutter apps, but improper handling can lead to significant performance bottlenecks. Large, uncompressed images, incorrect caching strategies, or unnecessary resolutions often result in increased memory consumption, slower load times, and larger app sizes.
A common pitfall is directly using high-resolution images without scaling them down appropriately for the target devices:
Image.asset('assets/images/[email protected]');
This might render correctly but can cause excessive memory usage, particularly noticeable in scroll views or frequently rebuilt widgets. To optimize this, Flutter offers built-in parameters:
Image.asset(
'assets/images/banner.png',
cacheWidth: 800, // appropriate width based on layout
cacheHeight: 400, // appropriate height based on layout
);
Loading remote images without controlling their in-memory size can also degrade performance:
Image.network('https://example.com/image.jpg');
Optimizing this by specifying cacheWidth
and cacheHeight
significantly reduces memory usage:
Image.network(
'https://example.com/image.jpg',
cacheWidth: 400,
cacheHeight: 200,
);
Repeated downloads of network images waste bandwidth and slow down the app. The cached_network_image
package efficiently caches images and provides placeholders and error handling:
CachedNetworkImage(
imageUrl: 'https://example.com/image.jpg',
placeholder: (context, url) => const CircularProgressIndicator(),
errorWidget: (context, url, error) => const Icon(Icons.error),
);
For logos and scalable icons, raster images can lead to unnecessary bloat and scaling issues. Using vector graphics like SVG provides resolution independence and smaller file sizes:
SvgPicture.asset('assets/icons/logo.svg');
A common mistake is applying opacity directly to images using Opacity
, creating expensive compositing layers:
Opacity(
opacity: 0.5,
child: Image.asset('assets/images/photo.jpg'),
);
DCM's avoid-incorrect-image-opacity
rule flags this inefficiency. Apply opacity directly to the image to avoid compositing:
Image.asset(
'assets/images/photo.jpg',
color: Colors.black.withOpacity(0.5),
colorBlendMode: BlendMode.dstIn,
);
DCM's analyze-assets
command identifies issues such as oversized images, incorrect resolutions, unused assets, and improper naming conventions.
For example, you can detect oversized images:
dcm analyze-assets lib --size=100KB
Or find images that can benefit from converting to WebP:
dcm analyze-assets lib --webp
This tool also identifies missing or incorrect high-resolution variants to ensure consistent asset quality across devices:
dcm analyze-assets lib --resolution
Proactive image optimization significantly improves your app's performance and user experience. Leveraging Flutter's built-in optimizations alongside DCM’s asset analysis helps keep your app lean, responsive, and visually crisp.
Poor Error Handling
Many times, I have dragged into a project, opened a code base and I have seen poor error handlings. Effective error handling in Flutter is critical, especially as applications scale. Relying solely on basic try/catch
blocks or inconsistent patterns can lead to silent failures, untraceable exceptions, and challenging debugging sessions. Here are a few patterns that I have observed in the past.
A frequent issue is catching errors without taking meaningful action:
try {
final data = await fetchData();
} catch (e) {
// ❌ LINT: empty catch block
}
This is leading to unnoticed bugs. It's always important you handle errors accordingly.
try {
final data = await fetchData();
} catch (e, stack) {
logError(e, stack);
showToast('Something went wrong');
}
Throwing new exceptions within a catch block can cause the original stack trace to be lost:
try {
await saveItem();
} catch (e) {
throw SaveException('Failed to save'); // ❌ LINT: loses original stack trace
}
DCM's avoid-throw-in-catch-block
highlights this. Use Error.throwWithStackTrace
to preserve the original context:
try {
await saveItem();
} catch (e, stack) {
Error.throwWithStackTrace(SaveException('Failed to save'), stack); // ✅ preserves stack trace
}
Catch blocks containing only rethrow
without additional handling are redundant and flagged by DCM’s avoid-only-rethrow
rule:
try {
// some operation
} catch (e) {
rethrow; // ❌ LINT: redundant catch block
}
Either handle specific exceptions or remove the catch block:
try {
// some operation
} catch (e) {
if (e is SpecificException) {
handleSpecificException(e);
} else {
rethrow;
}
}
Repeated identical catch blocks across your codebase lead to poor maintainability.
try {
await fetchUser();
} catch (e) {
logError(e);
showToast('Error occurred');
}
try {
await fetchSettings();
} catch (e) {
logError(e);
showToast('Error occurred');
}
The avoid-identical-exception-handling-blocks
rule identifies such duplication. Abstract this logic:
Future<T?> safeCall<T>(Future<T> Function() task) async {
try {
return await task();
} catch (e) {
logError(e);
showToast('Something went wrong');
return null;
}
}
await safeCall(fetchUser);
await safeCall(fetchSettings);
Using Dart's latest features like pattern matching and structured error handling:
Future<Result<User, FetchError>> fetchUser();
final result = await fetchUser();
switch (result) {
case Success(:final value):
// handle success
case Failure(:final error):
// handle error
}
This explicit handling pattern prevents unanticipated exceptions and makes error flows clearer.
Dart provides built-in Exception
and Error
types. Always throw meaningful exceptions, below is an good example:
throw FormatException('Invalid input provided');
Using Assertions for Development:
Assertions (assert
) validate assumptions during development:
assert(url.startsWith('https'), 'URL must start with "https"');
These checks are stripped in production builds and ensures no runtime overhead.
Ineffective Testing
Writing and maintaining tests is as critical as writing your application code. As Flutter apps grow, relying solely on manual testing quickly becomes unsustainable.
One common mistake is writing tests without any assertions, meaning the tests pass even if the code doesn't behave as intended:
test('fetches user data', () async {
await fetchUser();
}); // ❌ LINT: missing test assertion
DCM’s missing-test-assertion
rule flags this oversight. Every test should explicitly validate expected behavior or outcomes:
test('fetches user data', () async {
final user = await fetchUser();
expect(user.name, equals('Alice'));
});
Another frequent issue is duplicating the same assertion multiple times within a single test, adding no value and making tests harder to read:
test('form validation', () {
final result = validateEmail('[email protected]');
expect(result, isTrue);
expect(result, equals(true)); // ❌ LINT: duplicate assertion
});
DCM’s avoid-duplicate-test-assertions
rule catches redundant assertions, prompting concise and focused tests:
test('form validation', () {
final result = validateEmail('[email protected]');
expect(result, isTrue);
});
Using incorrect matchers or literals can lead to misleading test outcomes or false positives:
const _someNumber = 1;
const _someString = '1';
const _someList = [1, 2, 3];
// ❌ LINT: Target expression is not a 'List'.
expect(_someString, isList);
// ❌ LINT: Target expression does not have a 'length' property.
expect(_someNumber, hasLength(1));
DCM’s avoid-misused-test-matchers
rule identifies incorrect or nonsensical matcher usage. Tests should always use matchers properly aligned with tested values:
expect(_someString, isA<String>());
expect(_someList, hasLength(3));
Tests should also avoid using literal values directly and instead rely on matchers provided by the test package for clearer failure messages:
final array = [1, 2, 3];
expect(array.length, 1); // ❌ LINT: Prefer test matchers instead of literal values.
expect(array, hasLength(1)); // Correct
const value = 'hello';
expect(value is String, isTrue); // ❌ LINT: Prefer the 'isA<T>' matcher.
expect(value, isA<String>()); // Correct
This is highlighted by DCM’s prefer-test-matchers
rule, ensuring more readable tests and better diagnostics when tests fail.
Tests with asynchronous code should use appropriate matchers such as expectLater
rather than improperly mixing await
with expect
.
The DCM's prefer-expect-later
rule is another rule that helps for tests. It warns when a Future
is passed to expect without it being awaited. Generally, not awaiting a Future
passed to expect can lead to unexpected results. You can replacing it with expectLater
will show a warning for a not awaited Future
.
test('async result', () async {
await expectLater(getValue(), completion(equals(42)));
});
Empty test groups indicate unfinished refactoring or mistakenly added placeholders. DCM's avoid-empty-test-groups
rule helps identify these:
// ❌ LINT: This test group does not contain any test cases.
group('empty group', () {});
Instead, ensure each test group is meaningful and contains relevant tests:
group('user management', () {
test('creates user', () async {
// actual test assertions
});
});
For widget and integration tests, validating concrete user interactions or outcomes provides clearer documentation and reliability:
// Less informative
expect(find.byType(Text), findsOneWidget);
// More informative
expect(find.text('Welcome'), findsOneWidget);
Integrating tests into a continuous integration (CI) environment ensures early detection of regressions.
Pairing automated tests with static analysis tools like DCM results in greater confidence about your app’s correctness and maintainability.


Ultimately, effective testing isn't just about coverage—it's about clearly verifying your app’s behavior, documenting expectations explicitly, and providing immediate feedback when unexpected changes occur.
Package Overload
Let’s be honest, how many times have you added a package, refactored the code later, and just left that package sitting in pubspec.yaml
, completely unused? It happens. Flutter’s ecosystem is massive, and pub.dev
makes it incredibly easy to install third-party packages but that same convenience often leads to clutter, overuse, or poor maintenance issues.
One of the most common mistakes is pulling in a full-featured package just for a tiny feature. For example, using rxdart
just to debounce a text input:
dependencies:
rxdart: ^0.27.0
Then using it like this:
debounceStream = controller.stream.debounceTime(const Duration(milliseconds: 300));
This works, but it adds multiple transitive dependencies, increases binary size, and may lead to version conflicts down the road. For many simple cases, a plain Timer
is more than enough:
Timer? _debounce;
void onChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () {
// handle input
});
}
Another overlooked issue is dead packages, ones that were used before, but no longer referenced anywhere after a refactor. The package still lives in pubspec.yaml
, but nothing in your project touches it anymore. You’d never notice… unless you check.
That’s where DCM’s check-dependencies
command helps. It scans your code and flags:
- Unused packages that are still listed in
pubspec.yaml
- Over-promoted packages (declared as
dependencies:
but only used indev_dependencies:
context) - Under-promoted ones
- Packages missing from your pubspec that are being used
- And even packages you forgot to clean up after code generation
To use it, run:
dcm check-dependencies lib
Another issue is over relying on third-party APIs in your business logic. A common example:
final auth = FirebaseAuth.instance;
auth.signInWithEmailAndPassword(email: email, password: pass);
This works fine, but now your logic is tightly coupled to Firebase. Testing becomes harder, mocking is a pain, and swapping providers is nearly impossible. Wrapping third-party packages in your own abstraction layer makes your codebase far more flexible:
abstract class AuthService {
Future<void> signIn(String email, String password);
}
class FirebaseAuthService implements AuthService {
Future<void> signIn(String email, String password) {
return FirebaseAuth.instance.signInWithEmailAndPassword(
email: email,
password: password,
);
}
}
And finally, some packages might encourage global access patterns or static usage that feels convenient at first but becomes a maintenance headache later. Avoid turning your app into a tightly coupled web of package calls scattered everywhere.
Ignoring Screen Variability
It’s easy to design a Flutter UI that looks great on one device, say, a Pixel 6, but breaks when run on a tablet, in landscape mode, or on foldables. I often saw these in following patterns, hardcoded dimensions, fixed paddings, and rigid layouts that don’t adapt to different screen sizes.
A common example:
Container(
width: 300,
height: 200,
child: const Text('Static layout'),
);
This might render correctly on a mid-sized phone, but it can cause overflow on smaller screens or look oddly undersized on tablets.
Even something as simple as:
SizedBox(height: 20),
can become problematic if repeated without context across the UI.
DCM helps catch these inflexible patterns with the no-magic-number
rule. It warns when number literals (like 24
, 300
, 0.25
) are used directly in layout code instead of being extracted to named constants. While this rule isn’t specific to screen size adaptation, it encourages reusable, and often more responsive code.
Instead of:
Padding(
padding: EdgeInsets.symmetric(horizontal: 24), // ❌ Lint
child: Text('Hello'),
);
Prefer defining it explicitly:
const double pagePadding = 24;
Padding(
padding: EdgeInsets.symmetric(horizontal: pagePadding),
child: Text('Hello'),
);
Or better yet, base it on screen dimensions:
final width = MediaQuery.of(context).size.width;
Padding(
padding: EdgeInsets.symmetric(horizontal: width * 0.05),
child: Text('Hello'),
);
This makes the padding automatically scale with screen size, improving layout adaptability.
For more advanced responsiveness, LayoutBuilder
lets you adapt the UI based on available space:
LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth > 600) {
return WideLayout();
} else {
return NarrowLayout();
}
},
);
Other Flutter widgets that promote adaptability include:
Expanded
andFlexible
AspectRatio
FractionallySizedBox
MediaQuery
for screen size and orientationOrientationBuilder
for reacting to rotation changes
When handling layout changes between portrait and landscape, it's useful to adjust properties like crossAxisCount
in a GridView
or layout behavior in a PageView
:
final isPortrait = MediaQuery.of(context).orientation == Orientation.portrait;
GridView.count(
crossAxisCount: isPortrait ? 2 : 4,
children: items.map(buildItem).toList(),
);
Flutter’s DevTools and device simulators support screen size previews, but they don’t automatically test all variations. It’s a good habit to regularly test UIs on tablets, in landscape mode, and with text scaling to catch edge cases early.
DCM is a code quality tool that helps your team move faster by reducing the time spent on code reviews, finding tricky bugs, identifying complex code, and unifying code style.
Misusing BuildContext
BuildContext
is central to how Flutter locates widgets, themes, and inherited data. But it’s only valid during the lifetime of the widget’s position in the tree. Misusing it especially after an await
or during early lifecycle methods can lead to runtime exceptions or unpredictable behavior.
A common case looks like this:
Future<void> fetchAndNavigate() async {
final result = await fetchData();
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => DetailsPage(data: result)),
);
}
If the widget is disposed while fetchData()
is running, the context
is no longer valid. This causes:
Error: Looking up a deactivated widget’s ancestor is unsafe.
Yes, you may have now recognized this, because we have talked about this two times in this article again. But let's review it again.
The fix is to check if the widget is still mounted before using the context:
Future<void> fetchAndNavigate() async {
final result = await fetchData();
if (!mounted) return;
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => DetailsPage(data: result)),
);
}
This is related to, but distinct from, the mistake of calling setState()
after await
, which DCM already flags using use-setstate-synchronously
. I have already mentioned about this rule. The same async gap pattern applies here.
Another poor misuse is calling BuildContext
dependent APIs inside initState()
:
void initState() {
super.initState();
final theme = Theme.of(context); // ❌ Lint: context not ready
}
The widget is not fully mounted during initState()
, so context lookups may fail or return unexpected defaults. Flutter’s documentation recommends delaying context usage until didChangeDependencies()
or using a post frame callback:
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final theme = Theme.of(context); // Safe here
});
}
This also applies when trying to show snackbars or dialogs directly in initState()
:
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Loaded')),
);
Instead:
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Loaded')),
);
});
In navigation heavy apps, another mistake is passing a BuildContext
to another class or method, and storing it for later use. For example:
class AuthService {
final BuildContext context;
AuthService(this.context);
void logout() {
Navigator.of(context).pushReplacementNamed('/login');
}
}
This tightly couples navigation to context lifetime and can cause crashes if the context becomes stale. A better approach is to expose callbacks, use a NavigatorKey
, or inject higher-level services that abstract navigation logic.
In short, BuildContext
is not a global handle it’s tied to the widget’s position and lifetime. Using it outside its valid scope leads to some of the trickiest bugs in Flutter apps. The earlier context boundaries are respected in the app structure, the more stable the widget behavior becomes.
Another common mistake related to BuildContext
is when you have multiple of them in one widget tree.
class MyOtherWidget extends StatelessWidget {
Widget build(BuildContext context) {
return Builder(builder: (_) {
// ❌ LINT: This 'BuildContext' variable is not the closest context variable.
// Try using the closest context instead.
return _buildMyWidget(context);
});
}
}
Using incorrect BuildContext
reference can lead to hard to spot bugs and can happen if the variable was renamed from the usual context name (e.g. to _ due to being previously unused). Luckily DCM's use-closest-build-context
rule can also help to prevent such an issue.
class MyOtherWidget extends StatelessWidget {
Widget build(BuildContext context) {
return Builder(builder: (context) { // correct, was renamed from '_' to 'context'
return _buildMyWidget(context);
});
}
}
Remembering about these small issues are hard and that's where a great code quality tool like DCM can help.
Inefficient Code Structure
I bet you have felt this in many projects before. As your Flutter codebase grows, structure quickly outweighs syntax. It’s easy to start with everything in one widget, but as screens evolve and features multiply, poorly organized code becomes a long-term liability—harder to test, debug, and scale.
A classic sign of this is mixing UI, state, and logic directly inside a StatefulWidget
:
class LoginForm extends StatefulWidget {
const LoginForm({super.key});
State<LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
final emailController = TextEditingController();
final passwordController = TextEditingController();
Future<void> submit() async {
final response = await http.post(
Uri.parse('https://api.example.com/login'),
body: {
'email': emailController.text,
'password': passwordController.text,
},
);
if (response.statusCode == 200) {
Navigator.of(context).pushReplacementNamed('/home');
}
}
Widget build(BuildContext context) {
return Column(
children: [
TextField(controller: emailController),
TextField(controller: passwordController),
ElevatedButton(onPressed: submit, child: const Text('Login')),
],
);
}
}
This works in small apps but as soon as features grow, this tightly coupled code becomes a bottleneck. Testing is harder. Refactoring becomes dangerous. And reusability goes out the window.
A cleaner approach separates UI, logic, and state:
class LoginCubit extends Cubit<LoginState> {
final AuthService auth;
LoginCubit(this.auth) : super(LoginState.initial());
Future<void> login(String email, String password) async {
emit(state.copyWith(isLoading: true));
final result = await auth.signIn(email, password);
emit(state.copyWith(isLoading: false, result: result));
}
}
Then in your UI:
ElevatedButton(
onPressed: () {
final cubit = context.read<LoginCubit>();
cubit.login(email, password);
},
child: const Text('Login'),
);
This separation isn’t just about preferences, it’s about maintainability. Luckily DCM helps you enforce that structure in different ways:
avoid-bloc-public-fields
: Ensures internal state is kept private.avoid-bloc-public-methods
: Prevents exposing unnecessary public methods from Cubits or Blocs.cyclomatic-complexity
: Flags functions that try to do too much—making large build methods easier to spot and refactor.
For instance, instead of cramming UI logic into build
:
Widget build(BuildContext context) {
return Column(
children: [
_buildHeader(),
_buildForm(),
_buildActions(),
],
);
}
You can even extract each method into its own widget if state is minimal. This not only improves testability but also rebuild performance.
Beyond local refactors, DCM’s analyze-structure
command gives you a bird’s-eye view of how your project is laid out:
dcm analyze-structure lib | dot -Tpng -o structure.png
This generates a visual graph of which files depend on which highlighting circular dependencies, deeply nested imports, and violations of architectural boundaries.


You can group files by modules (--modules="/features/[^/]+"
) or packages (--per-package
), and quickly see if certain features are doing too much or importing things they shouldn't.
In short, there are several take away:
- Avoid putting networking or business logic directly inside widgets.
- Split UI into smaller components when build methods become long.
- Organize code by feature (e.g.
login/
,profile/
) not just by type (e.g.widgets/
,models/
). - Use DCM to spot overly complex methods, dead files, or unclear dependencies.
In the early days of an app, structure might not feel important. But a few months (and teammates) later, clarity becomes everything. With the right tools and habits—supported by DCM, you can scale without chaos.
Improper Use of GlobalKey
GlobalKey
is one of Flutter’s more powerful features and one of its most misused. It allows widgets to access state, trigger actions, and preserve identity across rebuilds. But using it unnecessarily introduces subtle bugs, degrades performance, and often signals a deeper architectural issue.
A typical misuse looks like this:
final globalKey = GlobalKey<FormState>();
Form(
key: globalKey,
child: ...
);
...
globalKey.currentState?.validate();
This isn’t inherently wrong Flutter’s form validation APIs are designed around GlobalKey
. But problems start when keys are:
- Reused across multiple widgets or screens
- Stored long-term and referenced outside the widget tree
- Created and passed down unnecessarily when a callback or state manager would suffice
The issue isn’t just about memory. GlobalKey
disables certain Flutter optimizations by requiring widgets to maintain identity between rebuilds—even when they don’t need to. This increases layout cost and risks subtle UI mismatches.
For example, this is a red flag:
final key = GlobalKey();
Widget build(BuildContext context) {
return Column(
children: [
SomeWidget(key: key),
AnotherWidget(key: key), // ❌ same key reused
],
);
}
Or:
class MyService {
final GlobalKey key;
MyService(this.key);
void doSomething() {
key.currentState?.doSomething(); // ❌ context misalignment risk
}
}
In both cases, the GlobalKey
is being treated as a global access point—which breaks encapsulation and leads to fragile code.
A better approach is to use callbacks, controllers, or state managers that are scoped to the widget:
class CustomWidget extends StatefulWidget {
final VoidCallback onComplete;
const CustomWidget({super.key, required this.onComplete});
State<CustomWidget> createState() => _CustomWidgetState();
}
class _CustomWidgetState extends State<CustomWidget> {
void _handleDone() {
widget.onComplete();
}
...
}
Or, in more advanced cases, using patterns like Provider
, Riverpod
, or Bloc
to expose the necessary state or control layer without relying on widget keys.
If you find yourself reaching for GlobalKey
to “get access to something from somewhere else,” it’s worth asking whether the widget hierarchy or app architecture needs a deeper refactor.
Abusing FutureBuilder
& StreamBuilder
FutureBuilder
and StreamBuilder
are incredibly convenient—offering a declarative way to handle async data in the widget tree. But they’re often misused, especially when the future or stream is re-created during every rebuild.
Here’s a common anti-pattern:
Widget build(BuildContext context) {
return FutureBuilder<User>(
future: fetchUser(), // ❌ new future on every build
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
return Text(snapshot.data?.name ?? 'No user');
},
);
}
This may appear to work but every call to build()
creates a new Future
, causing the widget to restart the loading process. That can result in:
- Repeated network calls
- Flickering loading indicators
- Cancelled async operations
- Higher battery and bandwidth usage
The fix is simple: store the Future
or Stream
ahead of time, such as in initState()
:
late final Future<User> _userFuture;
void initState() {
super.initState();
_userFuture = fetchUser();
}
Widget build(BuildContext context) {
return FutureBuilder<User>(
future: _userFuture,
builder: (context, snapshot) {
...
},
);
}
The same applies to StreamBuilder
:
StreamBuilder<Message>(
stream: FirebaseFirestore.instance.collection('messages').snapshots(), // ❌
builder: ...
);
If placed in build()
, this creates a new stream subscription every frame leading to memory leaks, dropped messages, or duplicate listeners.
Instead:
late final Stream<Message> _messageStream;
void initState() {
super.initState();
_messageStream = FirebaseFirestore.instance.collection('messages').snapshots();
}
These mistakes are often hard to spot during code reviews especially in large widgets with many conditional builds. DCM helps here by flagging async calls inside sync methods (like build()
or didChangeDependencies()
) using the avoid-async-call-in-sync-function
rule. It ensures that operations like fetchData()
or someStream.listen()
don’t sneak into parts of the UI lifecycle where they cause unintended side effects.
Additionally, if your async call is wrapped inside a method that returns a widget:
Widget _buildUserWidget() {
return FutureBuilder(future: fetchUser(), builder: ...);
}
DCM’s avoid-returning-widgets
rule can highlight this as a signal to refactor. It doesn’t just suggest style improvements it helps surface places where async logic is tightly coupled to layout, which is often a precursor to this issue.
In both cases, the goal is the same: isolate async logic and avoid rebuilding it unnecessarily. Flutter won’t warn you when you’re making the same network call 60 times per minute. But DCM will.
Improper Use of Widget Lifecycle Methods
Flutter’s widget lifecycle is full of subtle traps that can lead to performance issues, unexpected rebuilds, or runtime errors—especially when BuildContext
, setState()
, or asynchronous operations are involved. These issues often go unnoticed in small apps but compound in larger projects.


Take initState()
. It's the perfect place to initialize controllers or set up non-UI logic, but a common mistake is accessing context-dependent APIs like Theme.of
, ModalRoute.of
, or Provider.of
too early:
void initState() {
super.initState();
final theme = Theme.of(context); // ❌ Not safe: context not yet attached
}
This "works" sometimes—but not reliably. A better approach is to defer access using a post-frame callback:
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final theme = Theme.of(context); // ✅ Safe here
});
}
Misusing initState()
for async calls is another quiet bug source. Calling await
directly can cause setState()
to run after the widget is disposed if the user navigates away. DCM flags this exact problem with use-setstate-synchronously
, encouraging proper checks:
void initState() {
super.initState();
fetchData().then((data) {
if (!mounted) return; // ✅ Prevents exception
setState(() {
_data = data;
});
});
}
In fact, calling async methods from sync-only lifecycle hooks (like build
, initState
, or didChangeDependencies
) can lead to hard-to-catch bugs. DCM’s avoid-async-call-in-sync-function
catches these cases early, enforcing separation of async logic from sync hooks.
Another frequent misuse involves didChangeDependencies()
. This method is safe for accessing inherited widgets—but developers often run expensive logic here without realizing it gets called on every dependency change:
void didChangeDependencies() {
super.didChangeDependencies();
_loadData(); // ❌ Runs multiple times, e.g. on theme/locale change
}
Instead, guard it:
bool _isInitialized = false;
void didChangeDependencies() {
super.didChangeDependencies();
if (!_isInitialized) {
_loadData();
_isInitialized = true;
}
}
Calling setState()
inside didChangeDependencies()
or initState()
—especially when unnecessary—is another performance smell. DCM’s avoid-unnecessary-setstate
rule spots these patterns where setState()
is used even though no meaningful state change happens or the update could be done directly.
And don’t overlook didUpdateWidget()
. Many developers ignore this method altogether and instead rely on setState()
hacks to re-trigger logic when widget parameters change:
void didUpdateWidget(covariant MyWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.userId != widget.userId) {
_reloadUserData(); // ✅ Responds properly to updated input
}
}
Not using didUpdateWidget()
leads to messy rebuilds and stale state. With DCM keeping an eye on common misuses, like calling setState()
after async gaps or inside build logic, you can catch these before they cause real issues.
Flutter’s lifecycle isn’t overly complex—but precision matters. Tools like DCM help guide that precision, surfacing risky patterns in real-time and nudging you toward predictable, maintainable, and efficient widget behavior.
Unmaintained Code and Files
You know that file you wrote six months ago for a feature that got scrapped? Or that helper function you replaced but kept “just in case”? Yeah—it’s still there. Unused, untested, and quietly adding weight to your codebase.
Unmaintained code is one of the most expensive forms of tech debt. It’s invisible during development but slows down onboarding, increases build times, and clutters pull requests. Over time, it becomes harder to tell what’s critical and what’s just… old.


DCM helps clean this up before it becomes a problem.
Files that are no longer imported. Classes that are never instantiated. Functions that were once useful but no longer called. You don’t need to guess. DCM’s check-unused-code
command surfaces all of it:
$ dcm check-unused-code lib
It detects unused:
- Classes, fields, properties, methods, functions
- Enums, extensions, mixins, type aliases
You can even include overridden members by passing --no-exclude-overridden
.
That is not all, you can also have a set of rules that help detect small pars of unused code (e.g. avoid-unused-generics
, avoid-unnecessary-patterns
, avoid-duplicate-patterns
. avoid-unused-assignment
, no-equal-conditions
, etc.).
Even bigger than unused code: entire Dart files sitting around unused. These often come from prototypes, renamed features, or long-forgotten experiments.
DCM’s check-unused-files
scans for .dart
files that aren’t referenced anywhere:
$ dcm check-unused-files lib
And if you want to skip public exports (like APIs or barrels), just add --exclude-public-api
.
In apps with multiple languages, localization files (.arb
, AppLocalizations
, etc.) tend to collect old keys that are never removed. DCM detects these with check-unused-l10n
:
$ dcm check-unused-l10n lib --class-pattern="^AppLocalizations$"
It’ll highlight string getters that aren’t used anywhere in your app.
Copy-paste once, it’s a shortcut. Copy-paste five times? It’s a maintenance trap.
DCM’s check-code-duplication
finds structurally similar functions even if variable names differ:
$ dcm check-code-duplication lib
It works for:
- Regular functions and methods
- Constructors
- Test cases
You can exclude @override
methods, limit the scan to your package only, or adjust the minimum number of statements with --statements-threshold
.
Conclusion
From memory leaks and lifecycle missteps to overusing setState
or mismanaging async logic, these mistakes can quietly erode your app’s performance, stability, and maintainability.
The good news? Most of them are preventable.
Whether through better architectural decisions, smarter state management, or simply respecting the widget lifecycle, these pitfalls can be caught early and avoided altogether.
But more importantly, tools like DCM.dev make that even easier by surfacing structural issues, enforcing best practices, and helping you write more predictable and maintainable Flutter and Dart code.
Remember to subscribe to our newsletter, YouTube, or social media to get the latest updates.
Enjoying this article?
Subscribe to get our latest articles and product updates by email.