Flutter code review checklist for engineering teams
Doing code review in Flutter usually feels simple while the project is still small. The screen opens, the build passes, the PR looks controlled, and the change moves forward. But that changes quickly. As the app grows, some decisions that seemed harmless start to weigh things down. A widget takes on too many responsibilities, state starts spreading, navigation becomes harder to follow, and even a small change starts requiring too much context.
That is why review in Flutter should not be treated as a mechanical approval step. It helps prevent the codebase from becoming more expensive to understand, test, and maintain over time. In many cases, this is exactly the stage where you can still fix the problem without turning the next sprint into rework.
If your team reviews Flutter pull requests often, some problems usually keep coming back: too much logic inside widgets, state that becomes harder to follow, unnecessary rebuilds, lifecycle issues, and tests that exist but do not protect the real risk of the change.
Quick Flutter code review checklist
- Is the widget carrying more responsibility than it should?
- Is state management still clear and consistent with the rest of the app?
- Are there unnecessary rebuilds or heavy work inside
build()? - Are async flows, lifecycle, and null safety being handled carefully?
- Does the test actually protect the behavior that changed?
| What to review | Why it matters in Flutter |
|---|---|
| Architecture and widget responsibility | Prevents screens from becoming the place where rendering, business logic, and data access get mixed together |
| State management | Makes flows easier to understand, test, and extend as the app grows |
| Performance and rebuilds | Helps avoid unnecessary rendering, heavy build() methods, and UI slowdowns that accumulate over time |
| Async, lifecycle, and null safety | Catches issues that look harmless in a PR but later turn into crashes, leaks, or unstable behavior |
| Tests and navigation | Protects the behavior that actually changed and reduces regressions in more sensitive flows |
When the interface starts concentrating too much
In Flutter, it is common for the UI layer to start simple and slowly become the place where everything happens. The widget renders, calls a service, handles loading, transforms data, controls local state, and still decides navigation. At first, this may seem practical. Later, readability gets worse and every adjustment requires too much care.
That is why the review needs to look beyond the visual result. It is not enough to know whether the screen works. It also needs to be clear whether that implementation will stay healthy when the project has more cases, more flows, and more people working on the same app.
The first filter is understanding how much responsibility stayed in the widget
When a widget starts carrying too much responsibility, the cost shows up quickly. The build() method grows, the tree becomes tiring to read, and callbacks start concentrating logic that should live somewhere else.
Some signs usually appear together:
- business logic mixed with rendering
- data transformation happening inside the screen
- too many nested widgets
- too many conditional blocks inside the tree
- callbacks that fetch, validate, and update state at the same time
Example: widget doing too much
class ProfileScreen extends StatefulWidget { const ProfileScreen({super.key}); @override State<ProfileScreen> createState() => _ProfileScreenState(); } class _ProfileScreenState extends State<ProfileScreen> { bool isLoading = false; User? user; Future<void> loadUser() async { setState(() => isLoading = true); final response = await api.getUser(); user = User.fromJson(response.data); setState(() => isLoading = false); } @override Widget build(BuildContext context) { return Scaffold( body: isLoading ? const CircularProgressIndicator() : Text(user?.name ?? ''), ); } }
This code might pass in a rushed review, but the screen has already become responsible for fetching, transforming the response, controlling loading, and rendering. Once error handling, retry, analytics, or cache come in, this section will become harder to sustain.
In this case, a useful comment would be:
“This screen is already concentrating rendering, fetching, and data transformation. If this logic moves to a state or service layer, the flow becomes more predictable and testing becomes simpler.”
This type of comment helps because it addresses the real problem, instead of staying at a generic observation about code cleanliness.
When business logic ends up in the UI, maintenance gets more expensive
This is a very common pattern in Flutter apps. The delivery goes out quickly, the PR looks focused, and nobody blocks it. But soon after, that screen has already become dependent on too much behavior.
When the interface fetches data, interprets the response, handles exceptions, and decides business rules at the same time, the code loses clarity. On top of that, testing gets more annoying and reusing part of the flow becomes unnecessary work.
Separating responsibilities here is not overengineering. It is a way to prevent the screen from becoming the place where everything gets mixed together.
Poorly handled state usually gets in the way more than layout
In many Flutter apps, the problem does not start in the interface itself. It appears when nobody can clearly understand who changes what, at what moment, and with what side effect.
A screen reads state from one place, updates it somewhere else, triggers a side effect in a third place, and depends on implicit behavior to keep working. The application still runs, but every change becomes more expensive.
That is why the review needs to look carefully at this flow:
- is it clear where the state comes from?
- does the transition between loading, success, and error make sense?
- does the screen depend too much on improvised local state?
- does the solution still align with the pattern the rest of the app already uses?
Example: too much local state for a screen that will grow
class CheckoutScreen extends StatefulWidget { const CheckoutScreen({super.key}); @override State<CheckoutScreen> createState() => _CheckoutScreenState(); } class _CheckoutScreenState extends State<CheckoutScreen> { bool isLoading = false; bool hasCoupon = false; String? errorMessage; PaymentMethod? selectedMethod; Future<void> submitOrder() async { setState(() => isLoading = true); try { await checkoutService.submit( hasCoupon: hasCoupon, paymentMethod: selectedMethod, ); } catch (e) { errorMessage = 'Could not finish order'; } setState(() => isLoading = false); } }
Here, the problem is not style. The flow is already carrying too many states inside the screen itself. If extra validation, retry, split payment, or a new business rule comes in, maintenance starts to suffer.
This is exactly the kind of case where review needs to act early. If the screen is already growing in the wrong direction, waiting for a few more deliveries will only make the cost of fixing it worse.
Performance problems almost always creep in slowly
Not every performance problem in Flutter appears as something obvious. Most of the time, it builds up through small choices. A rebuild larger than necessary, an inefficient list, heavy work inside build(), careless use of const.
The review does not need to become an obsessive hunt for micro-optimization. Even so, it should block decisions that, when repeated several times, end up making the app heavier than it needs to be.
Example: more rebuilds than the screen needs
@override Widget build(BuildContext context) { final cart = context.watch<CartProvider>(); return Column( children: [ Header(total: cart.total), ShippingSummary(address: cart.address), PaymentSection(method: cart.paymentMethod), RecommendedProducts(items: cart.recommendations), FooterButton(onPressed: cart.checkout), ], ); }
If any change in CartProvider rebuilds this entire column, the cost grows quickly on larger screens. In many scenarios, better separating the listening points already improves readability and also reduces unnecessary rendering.
Async, null safety, and lifecycle need more care than they usually seem to
This is the kind of detail that slips through easily when the review is rushed. The code looks correct at first glance, the flow runs, and nobody sees an error in manual testing. Even so, the problem may already be there.
Some examples show up often:
- excessive use of
! - incomplete async error handling
- poorly handled loading
- controller without
dispose() - forgotten stream or subscription
- state update after the widget has left the tree
Example: controller created without disposal
class _LoginFormState extends State<LoginForm> { final emailController = TextEditingController(); final passwordController = TextEditingController(); @override Widget build(BuildContext context) { return Column( children: [ TextField(controller: emailController), TextField(controller: passwordController), ], ); } }
This is a simple and common case. The form works, but the controllers were left without dispose(). On a small screen, this can easily slip through. In a larger app, this kind of detail can create leaks and strange behavior over time.
The fix is direct:
class _LoginFormState extends State<LoginForm> { final emailController = TextEditingController(); final passwordController = TextEditingController(); @override void dispose() { emailController.dispose(); passwordController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Column( children: [ TextField(controller: emailController), TextField(controller: passwordController), ], ); } }
This is exactly the kind of problem a careful review can block early.
Bad navigation almost never appears all at once
In larger projects, navigation usually becomes harder little by little. One more condition comes in, then an extra redirect, then a flow exception, and by the time the team notices, the screen is already deciding too much navigation.
The risk here is not just making the code ugly. The risk is creating a flow that is hard to test and hard to change when product changes an important step.
Tests only help when they cover what actually changed
In Flutter, it is easy to accept tests as a checklist. The PR has a test, so it seems enough. But the more useful question here is different: does this test protect the sensitive part of the change?
If the change touches error handling, that is what the test needs to cover. If it changes state or navigation, the test needs to protect that behavior. Otherwise, the team gains volume and stays exposed at the most delicate point.
Example: the test exists, but leaves the main risk out
If a PR changes the loading error behavior of a screen, but the new test only checks whether the title appears in the interface, the problem remains uncovered.
In that case, the review needs to be direct. The issue is not missing tests in general. The issue is missing a test where the regression can actually happen.
AI code review tools help more when they come before human review
In teams that review many PRs, AI usually helps a lot in the first pass. It reinforces team rules, catches repetitive patterns, and reduces part of the noise before human review.
In the Flutter context, this is usually useful in situations like:
- lifecycle problems
- inconsistent state usage
- architecture patterns the team has already decided to block
- sections that break the project’s pattern
- simple risks that show up in many PRs
A practical example with Kodus
Kodus lets you create custom rules in natural language directly in the app or inside the repository with markdown files in paths such as .kody/rules/**/*.md or rules/**/*.md, defining scope, severity, language, and review instructions. Source: Repository Rules
A useful example for Flutter teams is a rule to detect controllers and subscriptions created in State without disposal in dispose().
---
title: "Controllers and subscriptions created in State must be disposed"
scope: "file"
path: ["lib/**/*.dart"]
severity_min: "high"
languages: ["dart"]
buckets: ["performance", "maintainability"]
enabled: true
--- ## Instructions
Review Flutter StatefulWidget and State classes for objects that require cleanup.
- Flag TextEditingController, AnimationController, ScrollController, FocusNode, TabController and StreamSubscription created in State and not released in dispose().
- Treat this as high severity because it can cause leaks, stale listeners and hard-to-debug lifecycle issues.
- Ignore cases where ownership clearly belongs to another layer and disposal is handled there.
## Examples
### Bad example
```dart
class _LoginFormState extends State {
final emailController = TextEditingController();
@override
Widget build(BuildContext context) {
return TextField(controller: emailController);
}
}
Good example
class _LoginFormState extends State<LoginForm> { final emailController = TextEditingController(); @override void dispose() { emailController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return TextField(controller: emailController); } }
This type of rule helps because it catches a recurring problem that often slips through when the PR is large or the review is rushed.
A practical code review checklist for Flutter
If you already have an internal checklist, the best path is usually to adapt that material to how the team reviews PRs today. For Flutter, I would use something along these lines.
Architecture and organization
- Does the PR follow the architecture pattern adopted by the project?
- Are responsibilities well separated between UI, business logic, and data access?
- Does the folder and file structure remain consistent with the rest of the app?
- Did the change make the screen more coupled than it needed to be?
State and navigation
- Does state management remain consistent with the project’s pattern?
- Is there too much business logic inside widgets?
- Is the loading, success, and error flow clear?
- Does navigation remain predictable, including pop, return, and more sensitive flows?
UI and experience
- Does the interface reuse components when it makes sense?
- Does the screen remain responsive across different sizes?
- Does the code respect the app’s theme, typography, and visual patterns?
- Are loading, empty, and error states clear for the person using the screen?
Performance
- Does the PR avoid unnecessary rebuilds?
- Is
constbeing used where it makes sense? - Do lists and grids use builders when needed?
- Is there unnecessary heavy processing inside
build()? - Were prints, excessive logs, and dead code removed?
Async, lifecycle, and null safety
- Is the async flow clear and does it handle failure correctly?
- Is there excessive use of
!or fragile null handling? - Are controllers, focus nodes, and subscriptions disposed correctly?
- Is there any risk of updating state after the widget has left the tree?
Models, integration, and failure handling
- Are models and DTOs well defined?
- Does data parsing and validation make sense for this change?
- Were timeout, network error, and offline state considered when needed?
- Does the failure provide clear feedback to the user?
Security
- Is there no hardcoded sensitive data?
- Is secure storage being used when needed?
- Do logs avoid exposing sensitive information?
Tests and quality
- Are there tests for the critical logic in this change?
- Do the tests actually protect the behavior that changed?
- Does everything pass with
dart formatand the project’s lint rules? - Do class, method, and variable names help explain the intent of the code?
In the end, Flutter review affects app maintenance more than PR approval
When review is shallow, the PR goes in quickly, but the bill comes later. On the other hand, when the team reviews with more care, it becomes easier to prevent the codebase from becoming hard to understand, test, and evolve.
In Flutter, this shows up early. If the codebase starts degrading, the impact is not only in the code. It shows up in the team’s pace, in the confidence to touch an old screen, and in the amount of context each change starts to require.