Best Practices
Flutter engineering practices — project structure, Widget organization, Keys, assets, theming, i18n
Best Practices
Production-validated Flutter engineering practices. The guiding principles: minimize rebuild scope, keep responsibilities single, make platform differences explicit.
Project Structure
Recommended Layering
lib/
├── main.dart
├── app/ Application entry configuration
│ ├── app.dart MaterialApp / CupertinoApp
│ ├── router.dart Routing config (go_router)
│ └── theme.dart Theme, color palette, typography
├── core/ Cross-cutting reusable utilities
│ ├── network/ Dio client, interceptors
│ ├── storage/ SharedPreferences, Hive wrappers
│ ├── utils/ Pure utility functions
│ └── extensions/ Dart extension methods
├── features/ Sliced by business feature (recommended)
│ └── order/
│ ├── data/ Repository, DTO
│ ├── domain/ Entity, UseCase
│ └── presentation/ Page, Widget, ViewModel
└── shared/ UI components shared across features
└── widgets/Type-based layering scales poorly. Flat pages/, widgets/, models/ folders work for tutorials but become hard to navigate once a project has dozens of pages, and they make it costly to extract a feature into a package later. Feature-based slicing — grouping everything related to one bounded context together — is what most production codebases converge on; consider it the default unless you have a specific reason to choose otherwise.
File Naming
| Type | Convention | Example |
|---|---|---|
| Files | snake_case | order_detail_page.dart |
| Class names | PascalCase | OrderDetailPage |
| Private classes/methods | underscore prefix | _buildHeader() |
| Constants | lowerCamelCase (not SCREAMING_SNAKE) | defaultTimeout |
Widget Organization
Splitting Rule: build method ≤ 3 screens
If a build method scrolls beyond 3 screens, split it. Priority order:
- Extract into a dedicated StatelessWidget class (best — gets
constfor free, isolates rebuilds) Extract into(acceptable, but losesWidget _buildXxx()methodsconstand rebuild isolation)Inline everything(worst)
// Not recommended: method extraction
class OrderPage extends StatelessWidget {
Widget _buildHeader() => Container(...); // Always rebuilds with parent
Widget _buildBody() => Column(...);
@override
Widget build(BuildContext context) {
return Scaffold(body: Column(children: [_buildHeader(), _buildBody()]));
}
}
// Recommended: class extraction
class OrderPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Column(children: [_OrderHeader(), _OrderBody()]),
);
}
}
class _OrderHeader extends StatelessWidget {
const _OrderHeader();
@override
Widget build(BuildContext context) => Container(...);
}Embrace const
const-constructed Widgets are instantiated at compile time and skip build at runtime:
// Cannot be const — new instance on every rebuild
Padding(padding: EdgeInsets.all(8), child: Text('Hi'))
// Can be const — entire subtree is reused
const Padding(padding: EdgeInsets.all(8), child: Text('Hi'))Enable the prefer_const_constructors and prefer_const_literals_to_create_immutables lint rules to enforce this.
Prefer StatelessWidget
Only reach for StatefulWidget when the component must hold mutable state. If a value can be passed in via props, do not stash it in State.
Using Keys Correctly
Keys are not decoration — using them incorrectly leads to scrambled state.
When Keys Are Required
| Scenario | Key Type | Why |
|---|---|---|
| List items can be reordered / inserted / removed | ValueKey / ObjectKey | Element tree reuses State by Key |
| Need to preserve State when transitioning between Widget types | GlobalKey | Identity across positions |
| AnimatedSwitcher subtree changes | ValueKey | Triggers the animation |
| Accessing State from elsewhere in the tree | GlobalKey | Rare — use sparingly |
Classic Bug: Checkbox List Misalignment
// BUG: after removing the first item, checked states shift to wrong rows
ListView(
children: items.map((item) => CheckableTile(item: item)).toList(),
)
// FIX: use a stable identifier as the Key
ListView(
children: items.map((item) =>
CheckableTile(key: ValueKey(item.id), item: item)
).toList(),
)Use GlobalKey sparingly: each GlobalKey must be unique across the entire app, and moving the widget triggers deactivate/activate of the whole subtree — expensive. If an InheritedWidget or state library can do the job, prefer that over GlobalKey.
BuildContext Conventions
Always check mounted after async gaps
// BUG: widget may be unmounted by the time the await resolves
Future<void> onTap() async {
final result = await api.fetch();
Navigator.push(context, ...); // crashes if widget unmounted
}
// FIX
Future<void> onTap() async {
final result = await api.fetch();
if (!mounted) return;
Navigator.push(context, ...);
}Flutter 3.7+ ships the use_build_context_synchronously lint to enforce this.
Do not mix context.read and context.watch
- Subscribe inside
build:context.watch<T>()(rebuilds on change) - Read once inside an event callback:
context.read<T>()(no subscription) - Inside
initState: usecontext.read<T>()(watchis not allowed)
Asset Management
Image Assets
# pubspec.yaml — declare directories rather than individual files
flutter:
assets:
- assets/images/
- assets/images/2.0x/ # high-density variants
- assets/images/3.0x/Flutter automatically picks the matching density based on MediaQuery.devicePixelRatio.
Font Assets
flutter:
fonts:
- family: Inter
fonts:
- asset: assets/fonts/Inter-Regular.ttf
- asset: assets/fonts/Inter-Bold.ttf
weight: 700Only include the weights you actually use — every font file inflates the bundle.
Asset Generation
Use flutter_gen to generate strongly-typed asset references and avoid hardcoded path strings that break silently when files move.
Theming and Styling
Centralize It
Define all colors, font sizes, radii, and spacing in ThemeData and access them via Theme.of(context) from components:
// Recommended
Text('Hello', style: Theme.of(context).textTheme.titleLarge)
// Not recommended
Text('Hello', style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold))Custom ThemeExtension
class AppColors extends ThemeExtension<AppColors> {
final Color brand;
final Color success;
const AppColors({required this.brand, required this.success});
@override
AppColors copyWith({Color? brand, Color? success}) =>
AppColors(brand: brand ?? this.brand, success: success ?? this.success);
@override
AppColors lerp(ThemeExtension<AppColors>? other, double t) {
if (other is! AppColors) return this;
return AppColors(
brand: Color.lerp(brand, other.brand, t)!,
success: Color.lerp(success, other.success, t)!,
);
}
}
// Inject
ThemeData(extensions: [AppColors(brand: Color(0xFF6750A4), success: Colors.green)])
// Consume
Theme.of(context).extension<AppColors>()!.brandRouting
Prefer Declarative Routing
go_router is the officially recommended declarative routing solution, with deep linking, nested routes, and route guards built in:
final router = GoRouter(
routes: [
GoRoute(path: '/', builder: (_, __) => HomePage()),
GoRoute(
path: '/order/:id',
builder: (_, state) => OrderPage(id: state.pathParameters['id']!),
redirect: (_, state) => isLoggedIn ? null : '/login',
),
],
);Avoid scattering hand-written Navigator.push calls everywhere — they make permission checks, deep linking, and back-stack management painful.
Errors and Logging
Global Error Capture
void main() {
// Widget construction errors
FlutterError.onError = (details) {
FlutterError.presentError(details);
Sentry.captureException(details.exception, stackTrace: details.stack);
};
// Uncaught async errors
PlatformDispatcher.instance.onError = (error, stack) {
Sentry.captureException(error, stackTrace: stack);
return true;
};
runApp(MyApp());
}Replace print
print cannot be filtered by level or disabled in production. Use logger or the dart:developer log():
import 'dart:developer' as dev;
dev.log('order created', name: 'order.service', error: e, stackTrace: st);Enable the avoid_print lint.
Testing
| Type | Use Case | Speed |
|---|---|---|
| Unit | Pure logic, Service, Repository | Very fast |
| Widget | Single Widget rendering, interaction | Fast |
| Golden | Pixel-level UI comparison | Medium |
| Integration | End-to-end on device / simulator | Slow |
Pyramid: many Unit + moderate Widget + critical-path Integration + visual Golden.
Dependency Management
- Pin to major versions with
^1.2.3(allow patch updates) - Run
flutter pub outdatedperiodically - Pull in internal plugins via
path:orgit:ref to avoid pub.dev publishing overhead - Use melos to manage shared code in a monorepo
Lint baseline: pick one of flutter_lints (official, conservative) or very_good_analysis (stricter, opinionated) — they are both include:-style rule sets, so layering them on top of each other causes rules to override one another and is not recommended. Start with flutter_lints; switch to very_good_analysis if your team wants enforced strictness, then disable individual rules in analysis_options.yaml as needed.