Steven's Knowledge

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

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

TypeConventionExample
Filessnake_caseorder_detail_page.dart
Class namesPascalCaseOrderDetailPage
Private classes/methodsunderscore prefix_buildHeader()
ConstantslowerCamelCase (not SCREAMING_SNAKE)defaultTimeout

Widget Organization

Splitting Rule: build method ≤ 3 screens

If a build method scrolls beyond 3 screens, split it. Priority order:

  1. Extract into a dedicated StatelessWidget class (best — gets const for free, isolates rebuilds)
  2. Extract into Widget _buildXxx() methods (acceptable, but loses const and rebuild isolation)
  3. 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

ScenarioKey TypeWhy
List items can be reordered / inserted / removedValueKey / ObjectKeyElement tree reuses State by Key
Need to preserve State when transitioning between Widget typesGlobalKeyIdentity across positions
AnimatedSwitcher subtree changesValueKeyTriggers the animation
Accessing State from elsewhere in the treeGlobalKeyRare — 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: use context.read<T>() (watch is 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: 700

Only 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>()!.brand

Routing

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

TypeUse CaseSpeed
UnitPure logic, Service, RepositoryVery fast
WidgetSingle Widget rendering, interactionFast
GoldenPixel-level UI comparisonMedium
IntegrationEnd-to-end on device / simulatorSlow

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 outdated periodically
  • Pull in internal plugins via path: or git: 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.

On this page