Give Flutter/Dart PRs a dedicated review guide, and document `npx skills add` while keeping SKILL.md at the repo root so clone-into-~/.claude/skills still works. Co-authored-by: tt-a1i <tt-a1i@users.noreply.github.com>
24 KiB
Dart / Flutter Code Review Guide
Code review guidelines for Dart 3 and Flutter focusing on widget rebuilds, const constructors, null safety, isolates, async in
build, Riverpod/Bloc state pitfalls, platform channels, keys, disposal, and testability. Not a language tutorial.
Table of Contents
- Widget Rebuilds & Const Constructors
- Null Safety &
late - Isolates
- Async in
build - State Management: Riverpod & Bloc
- Platform Channels
- Keys
- Disposal & Lifecycle
- Testability
- Review Checklist
- References
Widget Rebuilds & Const Constructors
Flutter rebuilds are cheap only when the Element tree can skip work. A review should flag widgets that allocate new subtrees, closures, or Theme lookups on every frame when a const constructor or an extracted widget would keep the child Element.
Prefer const constructors where the subtree is static
// ❌ Bad: every parent rebuild allocates a new Text and Icon.
Widget build(BuildContext context) {
return Row(
children: [
Icon(Icons.star),
Text('Favorites'),
],
);
}
// ✅ Good: const widgets can be canonicalized and skipped on rebuild.
Widget build(BuildContext context) {
return const Row(
children: [
Icon(Icons.star),
Text('Favorites'),
],
);
}
Review questions:
- Are leaf widgets (
Text,Icon,SizedBox,Paddingwith constant insets)const? - Does a missing
conston a parent blockconston an entire subtree? - Is
constomitted because a single non-const argument (color fromTheme.of, a closure) poisons the constructor?
Extract widgets, not helper methods, when state should be isolated
A private method that returns a Widget is inlined into the caller's build. The returned widgets have no Element identity of their own, so they rebuild whenever the caller rebuilds.
class ProfilePage extends StatelessWidget {
const ProfilePage({super.key, required this.user});
final User user;
// ❌ Bad: `_header()` rebuilds with ProfilePage even when `user` is unchanged.
Widget _header() => Header(title: user.name);
@override
Widget build(BuildContext context) {
return Column(children: [_header(), const Feed()]);
}
}
// ✅ Good: a separate widget gets its own Element; Feed stays const.
class ProfilePage extends StatelessWidget {
const ProfilePage({super.key, required this.user});
final User user;
@override
Widget build(BuildContext context) {
return Column(
children: [
Header(title: user.name),
const Feed(),
],
);
}
}
Do not call setState for derived values
// ❌ Bad: derived data stored in State, forcing a rebuild to recompute a getter.
class CartBadge extends StatefulWidget { /* ... */ }
class _CartBadgeState extends State<CartBadge> {
int _count = 0;
void didUpdateWidget(CartBadge old) {
super.didUpdateWidget(old);
setState(() => _count = widget.items.length);
}
}
// ✅ Good: derive in build; setState only when the source data changes.
class CartBadge extends StatelessWidget {
const CartBadge({super.key, required this.items});
final List<Item> items;
@override
Widget build(BuildContext context) {
return Text('${items.length}');
}
}
Rebuild scope
setStateon a highStateobject rebuilds that subtree. Prefer lifting only the state that must be shared, and wrapping expensive children inRepaintBoundaryor extracting them.ListView(children: [...])builds every child. PreferListView.builder/SliverListfor long lists.- Passing a newly allocated
List/Mapor a new callback instance into a child that is otherwiseconst-eligible defeats child skip. Capture stable callbacks (void Function()stored on State) or useWidget.canUpdateidentity viaconst/ extracted widgets.
For general UI performance patterns see Performance Review Guide.
Null Safety & late
Dart's null safety is only as strong as the holes the code leaves (!, late, as). Treat those as review signals, not as style.
Avoid ! on values the compiler cannot prove
// ❌ Bad: bang operator hides a possible null and crashes at runtime.
void openProfile(User? user) {
Navigator.pushNamed(context, '/user/${user!.id}');
}
// ✅ Good: promote with a local or return early.
void openProfile(User? user) {
final id = user?.id;
if (id == null) return;
Navigator.pushNamed(context, '/user/$id');
}
late is a delayed crash, not a type
late without an initializer throws LateInitializationError on first read. late final with an initializer is lazy and is the usual legitimate case (expensive, once).
class Session {
// ❌ Bad: `late` field is read from another method with no constructor guarantee.
late String token;
bool get isReady => token.isNotEmpty;
}
class Session {
// ✅ Good: nullable until assigned; API makes absence visible.
String? token;
bool get isReady => token != null && token!.isNotEmpty;
}
class ThemeCache {
// ✅ Good: lazy `late final` with initializer; first read computes once.
late final Map<String, Color> _colors = _loadColors();
}
Review questions:
- Is
lateused because the author did not want?/ a constructor argument? - Is a
latefield assigned ininitStatebut read from a listener that can fire earlier (animation, platform callback)? - Are
as Foocasts used where a pattern (if (x case final Foo foo)) orispromotion would fail loudly and locally?
Fields do not promote
class ProfileTile extends StatelessWidget {
const ProfileTile({super.key, required this.user});
final User? user;
@override
Widget build(BuildContext context) {
// ❌ Bad: field promotion does not apply; this is a lint (`unchecked_use_of_nullable_value`).
// return Text(user.name);
// ✅ Good: promote a local.
final user = this.user;
if (user == null) return const SizedBox.shrink();
return Text(user.name);
}
}
Null-related crashes are still crashes. See Error Handling Guide for fail-fast vs. swallowing.
Isolates
📖 Cross-language concurrency patterns: Async & Concurrency Guide
Flutter's UI isolate must stay free of heavy JSON, image, and crypto work. Dart isolates do not share memory; messages must be sendable.
Do not block the UI isolate
// ❌ Bad: large JSON decode on the UI isolate janks frames.
final users = (jsonDecode(raw) as List)
.map((e) => User.fromJson(e as Map<String, dynamic>))
.toList();
// ✅ Good: `Isolate.run` (Dart 2.19+) / `compute` for one-shot work.
final users = await Isolate.run(() {
return (jsonDecode(raw) as List)
.map((e) => User.fromJson(e as Map<String, dynamic>))
.toList();
});
Message restrictions
// ❌ Bad: closures, ReceivePorts, and many plugin types are not sendable.
await Isolate.run(() => widget.onParsed());
// ✅ Good: send plain data; map back to UI types on the main isolate.
final dto = await Isolate.run(() => parseReport(bytes));
setState(() => report = Report.fromDto(dto));
Review questions:
- Is
jsonDecode/ image decode / encryption of non-trivial payloads on the UI isolate? - Does the isolate callback capture
BuildContext,State, or plugin controllers? - If a long-lived isolate is spawned, is
kill/pausepaired with widget disposal? - Are isolate failures surfaced, or does
await Isolate.runlack error handling?
compute from flutter/foundation.dart is a thin wrapper; prefer Isolate.run in Dart-only code. Neither is a thread pool — spawning per tiny call is more expensive than doing the work inline.
Async in build
build must stay synchronous and side-effect free. Futures created in build restart on every rebuild.
Do not start I/O or create a new Future in build
// ❌ Bad: new Future every rebuild; the previous request is abandoned.
Widget build(BuildContext context) {
return FutureBuilder<Profile>(
future: api.fetchProfile(id),
builder: (context, snap) { /* ... */ },
);
}
// ✅ Good: cache the Future in State; refetch when `id` changes.
class ProfileView extends StatefulWidget {
const ProfileView({super.key, required this.id});
final String id;
@override
State<ProfileView> createState() => _ProfileViewState();
}
class _ProfileViewState extends State<ProfileView> {
late Future<Profile> _future = api.fetchProfile(widget.id);
@override
void didUpdateWidget(ProfileView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.id != widget.id) {
_future = api.fetchProfile(widget.id);
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder<Profile>(
future: _future,
builder: (context, snap) { /* ... */ },
);
}
}
Keying ProfileView with ValueKey(id) also remounts State and starts a new fetch. Either approach is fine; a cached late final future with no didUpdateWidget goes stale when id changes.
The same rule applies to StreamBuilder: do not open a new stream in build. Create it once (initState, a provider, a Bloc) and cancel on dispose.
setState after await must check mounted
Future<void> _load() async {
final data = await api.fetch();
// ❌ Bad: widget may have been disposed during the await.
setState(() => _data = data);
}
Future<void> _load() async {
final data = await api.fetch();
if (!mounted) return;
setState(() => _data = data);
}
Use context.mounted (Flutter 3.7+) before using BuildContext after an async gap (Navigator, ScaffoldMessenger, showDialog). The use_build_context_synchronously lint exists because this is a crash class, not a style issue.
Do not mark build async
// ❌ Bad: build cannot be async; this does not compile, or a helper is abused.
Widget build(BuildContext context) async {
final user = await repo.user();
return Text(user.name);
}
// ✅ Good: hold async state in State / a notifier; build only reads it.
Fire-and-forget unawaited(load()) inside build is the same bug with extra steps.
State Management: Riverpod & Bloc
These examples use Riverpod and Bloc because they dominate Flutter reviews. The same pitfalls apply to Provider, GetX, Signals, and raw InheritedWidget: rebuild scope, side effects in build, and lifetime.
Riverpod: watch vs read, and rebuild scope
class CartButton extends ConsumerWidget {
const CartButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// ❌ Bad: watches the whole cart; button rebuilds on every item mutation.
final cart = ref.watch(cartProvider);
// ✅ Good: watch only the field this widget needs.
final count = ref.watch(cartProvider.select((c) => c.items.length));
return Badge(label: Text('$count'));
}
}
void onPressed() {
// ❌ Bad: `watch` in a callback / listener; it is not a rebuild subscription.
// ref.watch(cartProvider).add(item);
// ✅ Good: `read` for one-shot, `watch`/`select` only in `build` / `build` of a listen widget.
ref.read(cartProvider.notifier).add(item);
}
Review questions:
- Is
ref.watchused ininitState, a gesture handler, or aProvider's constructor? - Does a widget
watcha large object whenselect/ a derived provider would do? - Are providers that capture
refafterdispose(ref.onDisposemissing for controllers, streams)? - Is
autoDisposeomitted on screen-scoped providers so they leak after pop?
Bloc: create vs value, buildWhen, closed cubits
BlocProvider(create: ...) inside build is the documented pattern: create runs once per Element, not on every rebuild. updateShouldNotify does not control create. The bug is constructing a new bloc instance in build and handing it to BlocProvider.value (no ownership, no dispose, new object every rebuild). Because create captures id once, a parent that later passes a new id will not reload unless the Element remounts or you dispatch an update.
// ❌ Bad: new ProfileBloc() every rebuild; `value` does not dispose it.
Widget build(BuildContext context) {
return BlocProvider.value(
value: ProfileBloc()..add(ProfileStarted(id)),
child: const ProfileBody(),
);
}
// ❌ Bad: UniqueKey remounts every rebuild — `create` runs again each time.
Widget build(BuildContext context) {
return BlocProvider(
key: UniqueKey(),
create: (_) => ProfileBloc()..add(ProfileStarted(id)),
child: const ProfileBody(),
);
}
// ✅ Good: `create` owns one instance; ValueKey(id) remounts only when id changes.
class ProfilePage extends StatelessWidget {
const ProfilePage({super.key, required this.id});
final String id;
@override
Widget build(BuildContext context) {
return BlocProvider(
key: ValueKey(id),
create: (_) => ProfileBloc()..add(ProfileStarted(id)),
child: const ProfileBody(),
);
}
}
Alternatively, keep the same bloc and in didUpdateWidget dispatch ProfileRequested(id) when id changes — do not construct a replacement ProfileBloc() there.
// ❌ Bad: listener work inside builder; also rebuilds on every state.
BlocBuilder<ProfileBloc, ProfileState>(
builder: (context, state) {
if (state is ProfileFailure) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(context).showSnackBar(/* ... */);
});
}
return ProfileBody(state: state);
},
)
// ✅ Good: side effects in BlocListener; rebuilds filtered with buildWhen.
BlocConsumer<ProfileBloc, ProfileState>(
listenWhen: (p, c) => c is ProfileFailure && p is! ProfileFailure,
listener: (context, state) {
final message = (state as ProfileFailure).message;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
},
buildWhen: (p, c) => p.user != c.user || p.loading != c.loading,
builder: (context, state) => ProfileBody(state: state),
)
Review questions:
- Is a
Cubit/Blocclosed (BlocProviderdoes this iflazy/createowns it; a manually constructed bloc mustclose)? - Are events dispatched from
build? - Is
Equatable/==missing on state so everyemitrebuilds even when fields did not change? - Is business logic in the widget (
context.read<FooBloc>().addmixed with parsing, I/O) instead of the bloc?
Do not treat GetX Obx / GetBuilder as exempt: the same "who owns the controller, who rebuilds, who disposes" questions apply.
Platform Channels
Method channels, event channels, and Pigeon are a trust and lifetime boundary. Failures look like "missing plugin" at runtime, not at compile time.
// ❌ Bad: untyped maps, ignored errors, UI isolate blocked on a heavy native call.
final result = await MethodChannel('app.wallet').invokeMethod('sign', {'tx': raw});
final signature = result as String;
// ✅ Good: typed API (Pigeon or a wrapper), errors handled, no UI-thread assumption.
try {
final signature = await walletHost.sign(SignRequest(tx: raw));
if (!mounted) return;
onSigned(signature);
} on PlatformException catch (e) {
onSignFailed(e.code, e.message);
}
Review questions:
- Is the channel name a collision-prone generic string (
app,native) without a domain prefix? - Are Android/iOS implementations of the same method covering all argument types and null?
- Does the Dart side assume the plugin is registered (tests, background isolates, and add-to-app engines often are not)?
- Is binary data passed as
List<int>copies instead ofUint8List/ Pigeon bytes? - Are EventChannel subscriptions cancelled in
dispose? - Does native code do disk/network work on the platform main thread?
Platform data is untrusted input: validate it the same way you would a network payload. See Security Review Guide.
Keys
Keys preserve State / Element identity across rebuilds. Wrong keys cause state to attach to the wrong row; missing keys cause state to reset when the list reorders.
// ❌ Bad: no keys; after delete/reorder, TextField state sticks to the index.
ListView(
children: items.map((item) => TodoRow(item: item)).toList(),
)
// ❌ Bad: UniqueKey() in build: every rebuild is a new identity; state is always reset.
TodoRow(key: UniqueKey(), item: item)
// ✅ Good: a stable id from the model.
ListView(
children: [
for (final item in items)
TodoRow(key: ValueKey(item.id), item: item),
],
)
// ❌ Bad: GlobalKey created in `build` — a new key every rebuild, plus GlobalKey cost.
Widget build(BuildContext context) {
final key = GlobalKey<FormState>();
return Form(key: key, child: /* ... */);
}
// ✅ Good: GlobalKey is rare; hold it on State when FormState is actually needed.
class _EditorState extends State<Editor> {
final _formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) => Form(key: _formKey, child: /* ... */);
}
Review questions:
- Do reorderable / dismissible / animated lists use
ValueKey/ObjectKeyfrom a stable model id? - Is
GlobalKeyused to reach into a child'sStatewhen a callback /ValueNotifierwould do? - Are keys on widgets whose
runtimeTypealready uniquely identifies them (usually unnecessary)?
Disposal & Lifecycle
Anything with addListener, a subscription, a ticker, or a native peer needs a matching dispose. Flutter will not save you.
class SearchField extends StatefulWidget {
const SearchField({super.key});
@override
State<SearchField> createState() => _SearchFieldState();
}
class _SearchFieldState extends State<SearchField> with SingleTickerProviderStateMixin {
late final TextEditingController _controller;
late final AnimationController _anim;
StreamSubscription<Query>? _sub;
@override
void initState() {
super.initState();
_controller = TextEditingController();
_anim = AnimationController(vsync: this, duration: const Duration(milliseconds: 200));
_sub = queryStream.listen((q) {
if (mounted) _controller.text = q.text;
});
}
@override
void dispose() {
_sub?.cancel();
_anim.dispose();
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => TextField(controller: _controller);
}
Review questions:
TextEditingController,ScrollController,FocusNode,AnimationController,TabController,PageController— created? disposed?StreamSubscription,Timer,ChangeNotifierlisteners,WidgetsBindingObserver— removed?AnimationControlleruses aTickerProviderthat dies with theState(SingleTickerProviderStateMixin), not a leaked vsync?- Route-level controllers created in
BlocProvider/Provider—disposecallback set? Image.network/ video / camera controllers stopped when the route is covered, not only when popped?
dispose must not use BuildContext (the element is unmounted). Do not call setState there.
Testability
Review the tests the same way as the product code. Flutter tests that pump the whole app to assert a string are a smell that logic is trapped in widgets.
// ❌ Bad: parsing and I/O live in the widget; tests must pump and mock everything.
class PriceLabel extends StatelessWidget {
const PriceLabel({super.key, required this.raw});
final String raw;
@override
Widget build(BuildContext context) {
final n = NumberFormat.simpleCurrency().parse(raw);
return Text(n.toString());
}
}
// ✅ Good: pure function / mapper is unit-tested; widget only renders.
String formatPrice(String raw, NumberFormat format) => format.format(format.parse(raw));
class PriceLabel extends StatelessWidget {
const PriceLabel({super.key, required this.text});
final String text;
@override
Widget build(BuildContext context) => Text(text);
}
// ❌ Bad: test depends on real time and network.
testWidgets('loads profile', (tester) async {
await tester.pumpWidget(const ProfilePage(id: '1'));
await tester.pumpAndSettle();
expect(find.text('Ada'), findsOneWidget);
});
// ✅ Good: inject a fake; pump until the Future you control completes.
testWidgets('loads profile', (tester) async {
final api = FakeProfileApi()..completeWith(Profile(name: 'Ada'));
await tester.pumpWidget(ProfilePage(id: '1', api: api));
await tester.pump();
expect(find.text('Ada'), findsOneWidget);
});
Review questions:
- Can the new logic be tested with
flutter testwithoutIntegrationTestWidgetsFlutterBinding? - Are
Keys present on interactive widgets the test (and the reviewer) needs to find, without paintingUniqueKeyinbuild? - Are platform channels mocked (
TestDefaultBinaryMessengerBinding)? - Does
pumpAndSettlehang because an infinite animation / polling stream never idles? - Is
latein tests hiding missing setup?
Review Checklist
Widgets & rebuilds
- Static subtrees use
constconstructors - Expensive children are extracted widgets, not
_buildFoo()methods - Long lists use lazy builders, not a fully-materialized
children:list setStateis not used to store values that can be derived inbuild
Null safety & late
!andasare not used to silence the type systemlateis lazy-init (late final x = ...) or truly guaranteed before first read- Nullable fields are promoted via locals, not assumed non-null
Isolates & async
- Heavy JSON / image / crypto work is off the UI isolate
- Isolate messages are sendable plain data
builddoes not create Futures/Streams or start I/OFutureBuilder/StreamBuilderreuse a cached future/stream; refetch when the id/input changessetState/BuildContextafterawaitcheckmounted/context.mounted
State management
ref.watch/context.watchonly inbuild;readin callbacks- Rebuilds are narrowed (
select,buildWhen) - Do not construct a
Bloc/Cubit/ChangeNotifierinbuildand pass it to*.value— usecreate; key withValueKey(id)(notUniqueKey()) ordidUpdateWidgetso a new id reloads - Providers/blocs that own controllers are disposed (
autoDispose,close)
Platform, keys, disposal
- Platform channel calls handle
PlatformExceptionand missing plugins - EventChannel / native subscriptions are cancelled
- List children that hold
Statehave stableValueKeys (notUniqueKey()inbuild) - Controllers, tickers, and subscriptions are disposed;
disposedoes not usecontext
Tests
- Domain logic is unit-tested without pumping widgets
- Widget tests inject fakes; they do not hit network or real platform channels
pumpAndSettleis not used on never-idle animations