Best Practice #

Writing code that merely works is the first step, but writing clean, maintainable, and efficient code is the true professional standard. Google released the Effective Dart guide summarizing best practices learned from years of large-scale development. This article summarizes the essential writing rules you must apply in daily Flutter app development, from naming, type handling, async memory leak optimization, to structured error handling.

1. Apply Naming Conventions Disciplined #

Consistency is the main key to code readability. When all developers on a team use uniform naming conventions, our brains process logic patterns much faster without confusion.

In Dart, naming rules are divided into three main categories:

UpperCamelCase #

Used for naming special data types, like class names, enums, type aliases (typedef), generic type parameters, and extensions.

class UserProfile {}
enum OrderStatus { pending, success, failed }
typedef Predicate<T> = bool Function(T);
extension StringHelper on String {}

lowerCamelCase #

Used for naming variables, functions, parameters, method names, and named constructor names.

var totalPayment = 150000;
void fetchProductDetails() {}
User.fromJson(Map<String, dynamic> json);

lowercase_with_underscores #

Used absolutely for package naming, library file names (.dart), directory names (folders), and import prefixes.

// Correct directory & file names:
lib/data/data_sources/remote_data_source.dart

Private Member Conventions #

Use the underscore prefix (_) only to mark class members or top-level variables that are private to that library file. Never use underscores for local variables inside methods.

// ANTI-PATTERN: Using underscore for local variables
void processOrder() {
  var _tempId = '123'; // WRONG: Confuses linters and other developers
  var tempId = '123';  // CORRECT
}

2. Use final and const Aggressively #

Dart encourages you to write immutable code as much as possible. Marking variables as final or const doesn’t just protect data state from accidental changes — it also gives the Dart compiler important information for memory optimization.

  • const: Used for values that are definite and constant since the compilation stage (compile-time constant).
  • final: Used for variables whose values are only known when the app runs (runtime), but cannot be changed after the first initialization.
// Using const for global configuration values
const double piValue = 3.14159;
const int maxRequestTimeout = 5000; // milliseconds

// Using final for runtime-initialized objects
final currentTimestamp = DateTime.now();
final authRepository = AuthRepository();

The Importance of const on Flutter Widgets #

In the Flutter framework, writing the const keyword in front of static Widget literals is mandatory for optimal app performance.

Every time a page rebuilds (e.g., because of a setState call), Flutter skips Widgets marked const because their structure is guaranteed unchanged. This massively cuts CPU workload when rendering the Widget Tree.

// ANTI-PATTERN: Missing const on static elements inside a Column
Widget build(BuildContext context) {
  return Column(
    children: [
      Text('Hello Welcome'), // WRONG: Always reconstructed in heap memory
      SizedBox(height: 10),
      const Text('Hello Welcome'), // CORRECT: Uses a single constant reference
      const SizedBox(height: 10),         // CORRECT
    ],
  );
}

3. Apply Data Type Declaration Smartly #

Dart comes with a very smart Type Inference analysis feature. The compiler can accurately guess a variable’s data type based on the initial value given. Therefore, you don’t need to write types redundantly.

// ANTI-PATTERN: Verbose repetitive type declarations
Map<String, List<int>> userHistory = <String, List<int>>{};

// CORRECT: Let type inference work cleanly
final userHistory = <String, List<int>>{};

When Must You Write Types Explicitly? #

Although type inference is very helpful, writing types explicitly is mandatory in the following locations to maintain API documentation clarity:

  1. Input parameters and return values on public functions/methods.
  2. Public class-level variables exposed outside the module.
// CORRECT: Writing types explicitly on public method signatures
List<Product> filterProductsByCategory(List<Product> source, String category) {
  return source.where((p) => p.category == category).toList();
}

Avoiding dynamic #

Never use the dynamic keyword unless you truly have no other choice (e.g., when processing dynamic JSON data with irregular structures). Using dynamic disables all Type-Safety protections from the compiler, increasing the risk of runtime crashes when the app runs for users.

If you want to represent a type that could be anything but still maintain type safety, use the Object type (or Object? if null is allowed).

// ANTI-PATTERN: Using dynamic carelessly
void printValue(dynamic value) {
  // If value is an int type, the length call below will CRASH immediately at runtime!
  print(value.length); 
}

// CORRECT: Using Object for static type safety
void printValueSecure(Object value) {
  // The compiler will immediately trigger an ERROR before build if we access the length property
  // print(value.length); // ERROR
  
  if (value is String) {
    print(value.length); // Safe: The type is automatically promoted to String inside this block
  }
}

4. Handle Errors Structurally #

Poor error handling often causes the app to close abruptly (crash) or deceive users by showing an endless loading spinner. Dart provides a strong exception system for isolating system failures.

The structured error handling flow mechanism can be illustrated through the following flow diagram:

flowchart TD
    TryBlock["try block: Run Operation"] --> CatchCond{"Exception Occurred?"}
    CatchCond -- "No" --> Done["Finished Without Issues"]
    CatchCond -- "Yes" --> CheckType{"Matches 'on ExceptionSpec' type?"}
    CheckType -- "Yes" --> HandledSpec["Handle Specific Error"]
    CheckType -- "No" --> CatchAll{"Handle in general catch(e)?"}
    CatchAll -- "Yes" --> LogError["Log & Show Friendly Message"]
    CatchAll -- "No" --> Propagate["Send Error Upward (Crash/Global Handler)"]
    HandledSpec & LogError & Propagate --> Finally["finally block: Clean Up Resources (Always Runs)"]

Always Use Specific Custom Exceptions #

Never throw errors using the generic class object throw Exception('Message'). Always design custom exception classes implementing the Exception interface so the development team can differentiate error handling in an organized way.

// Creating a structured Custom Exception
class NetworkTimeoutException implements Exception {
  final String message;
  final int durationSeconds;

  NetworkTimeoutException(this.message, this.durationSeconds);

  @override
  String toString() => 'NetworkTimeoutException: $message after $durationSeconds seconds.';
}

Catch Errors by Type (on Clause) #

When writing try-catch blocks, get in the habit of catching error types specifically using the on clause. Never swallow all errors with an empty catch (e) block because that hides critical internal bugs.

// ANTI-PATTERN: Blindly catching and hiding all errors
try {
  loadConfiguration();
} catch (e) {
  return null; // Syntax bugs inside the method also get swallowed and become hard to debug!
}

// ====================================================================

// CORRECT: Catching directionally and letting other bugs stay exposed
try {
  await authService.connect();
} on NetworkTimeoutException catch (e) {
  // Handle the connection failure specifically
  showRetryButton(e.message);
} on SocketException catch (e) {
  showOfflineBanner();
} catch (e) {
  // Catch other unexpected errors and report to the crash reporting system (e.g. Firebase Crashlytics)
  crashlytics.log(e);
  rethrow; // Forward again to be caught by the global handler
}

5. Prevent Async Memory Leaks #

Long-running Flutter apps often experience gradually degrading performance until they eventually die from running out of RAM. The main cause of this problem is developers failing to manage asynchronous subscription objects.

The lifecycle monitoring flow of Stream subscriptions and their leak points can be seen in the diagram below:

flowchart TD
    Init["Initialization: listen() on Stream"] --> SubActive["Active Subscription Status in Memory"]
    SubActive --> EventFlow{"New Data?"}
    EventFlow -- "Yes" --> Proc["Process Data & Update UI"] --> SubActive
    EventFlow -- "No" --> CheckDestroy{"Widget/Page Destroyed?"}
    CheckDestroy -- "No" --> SubActive
    CheckDestroy -- "Yes" --> Disconnect{"Was cancel() called?"}
    Disconnect -- "Yes" --> GC["Memory Cleaned Up (Safe)"]
    Disconnect -- "No" --> Leak["Subscription Stays Hanging in Heap Memory (Memory Leak)"]

Mandatory Stream Closing Rules #

Every time you open a .listen() subscription connection to a Stream object or use a StreamController, you must cancel it inside the widget destruction lifecycle method (dispose).

class LocationTrackerState extends State<LocationTrackerWidget> {
  StreamSubscription<Position>? _gpsSubscription;
  final _controller = StreamController<Position>();

  @override
  void initState() {
    super.initState();
    // Opening the subscription
    _gpsSubscription = gpsService.onLocationChanged.listen((pos) {
      _controller.add(pos);
    });
  }

  @override
  void dispose() {
    // Mandatory: Close all async subscription connections
    _gpsSubscription?.cancel();
    _controller.close();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) => const SizedBox.shrink();
}

Using unawaited() for Fire-and-Forget #

The Dart analyzer will detect and show a warning message if you call an async function (returning a Future) without the await keyword in front of it.

If you deliberately want to let that operation run in the background without waiting for its result (Fire-and-Forget scenario, e.g., recording statistics logs), wrap the call with the unawaited() function from the dart:async package.

import 'dart:async';

void onUserClickButton() {
  showLoading();
  // The UI navigation operation runs without waiting for the analytics log process to finish
  navigateHome();
  
  // unawaited tells the analyzer we deliberately don't wait for this operation to finish
  unawaited(analyticsService.logClickEvent('home_button'));
}

6. Use Idiomatic Patterns: Cascade and Collection Operators #

Dart has various unique built-in operators specifically designed to shorten functional code writing to look more elegant.

Cascade Operator (.. and ?..) #

The cascade operator lets you perform a series of method calls or property changes on the same object in sequence without rewriting the object variable name repeatedly.

// ANTI-PATTERN: Repetitive object property filling
final paint = Paint();
paint.color = Colors.red;
paint.strokeWidth = 5.0;
paint.style = PaintingStyle.fill;

// ====================================================================

// CORRECT: Using the concise cascade operator
final cleanPaint = Paint()
  ..color = Colors.red
  ..strokeWidth = 5.0
  ..style = PaintingStyle.fill;

Using null-aware cascade (?..) #

If the initialized object could potentially be null, use the ?.. operator to keep the chained method executions below it safe:

Path? myPath;
myPath
  ?..moveTo(0, 0)
  ..lineTo(100, 100)
  ..close(); // Only executes if myPath is not null

Anti-Patterns to Avoid #

Here’s a summary of Dart code writing mistakes often found at the production level along with their corrected solution examples:

// 1. ✗ Checking Collection Emptiness Using Length Comparison
// ANTI-PATTERN: Slow because it triggers length element calculation from scratch
if (usersList.length == 0) { ... }
if (usersList.length > 0) { ... }

// ✓ Solution: Using the fast O(1) boolean getter
if (usersList.isEmpty) { ... }
if (usersList.isNotEmpty) { ... }

// ====================================================================

// 2. ✗ Mixing then() Callbacks with async/await
// ANTI-PATTERN: Breaks async flow reading consistency
Future<void> loadConfig() async {
  final file = await openConfigFile();
  readBytes(file).then((bytes) {
    print('Data: $bytes');
  });
}

// ✓ Solution: Use await consistently on all lines
Future<void> loadConfigClean() async {
  final file = await openConfigFile();
  final bytes = await readBytes(file);
  print('Data: $bytes');
}

// ====================================================================

// 3. ✗ Ignoring Linter Warnings
// DON'T: Leave analysis_options.yaml empty or ignore blue warnings in the editor.
// ✓ Solution: Enable Google's official linter rules (package:flutter_lints) at the project root.

Dart Code Review Checklist #

Use the structured checklist below as the main guide when doing team code review before merging changes into the main branch:

Naming & Structure #

  • All class names use the UpperCamelCase format.
  • Variables, methods, and parameters are written using the lowerCamelCase format.
  • Library files (.dart) and project folders are clean using the lowercase_with_underscores format.
  • Private class members are consistently prefixed with an underscore (_).

Type Management & Immutability #

  • Variables whose values don’t change after runtime initialization are declared using final.
  • All static widgets inside the Flutter widget tree are marked with the const keyword.
  • Avoid illegal use of the dynamic type; use Object for flexible yet safe data types.
  • Initialize collection literals with clean type inference (final list = <String>[]; not List<String> list = <String>[];).

Async & Error Handling #

  • No mixing between .then() style and async/await inside one function.
  • All StreamSubscription and StreamController are closed disciplinedly inside the dispose method.
  • Avoid blind error-catching blocks catch (e) {} without handling; use specific types via on.
  • All fire-and-forget async function calls are wrapped using the unawaited() method.

Summary #

  • Naming Conventions: Apply UpperCamelCase for class/enum types, lowerCamelCase for variables/methods, and lowercase_with_underscores for files and folders.
  • final & const: Use aggressively. Writing const on Flutter widgets is crucial for minimizing screen rendering load from rebuilds.
  • Type Inference: Let the compiler guess local data types through inference, and write explicitly only on public API function/method signatures.
  • dynamic: Avoid using the dynamic type to maintain Type-Safety guarantees; replace it with the Object type when needed.
  • Custom Exceptions: Design specific error classes inheriting custom Exception and catch them using the on clause to avoid accidentally swallowing bugs.
  • Prevent Memory Leaks: Always cancel async subscriptions by calling .cancel() and .close() inside the dispose() method.
  • unawaited: Use it to silence analyzer warnings on async operations deliberately run in the background (fire-and-forget).
  • Cascade Operator: Use the .. operator (and ?.. for nullable objects) to chain object property modifications cleanly and idiomatically.

← Previous: Isolate & Concurrency   Next: Overview →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact