Dart essentials for Flutter

Sound null safety, futures and streams, classes with mixins, collections, extension methods and async/await — the Dart you actually write inside a widget tree.

Null safety and simple models

class Task {
  final String id;
  final String title;
  final DateTime? dueDate;

  const Task({required this.id, required this.title, this.dueDate});

  bool get isOverdue {
    final due = dueDate;
    if (due == null) return false;      // promotion works inside a local
    return due.isBefore(DateTime.now());
  }

  Task copyWith({String? title, DateTime? dueDate}) =>
      Task(id: id, title: title ?? this.title, dueDate: dueDate ?? this.dueDate);
}

void main() {
  const task = Task(id: 'a1', title: 'Write release notes');
  final label = task.dueDate?.toIso8601String() ?? 'no date';
  print('$label ${task.isOverdue}');
}
  • final means assign once, const means known at compile time. A const widget subtree is never rebuilt.
  • ? makes a type nullable; ! asserts it is not null and throws at runtime if you are wrong.
  • Null promotion only works for local variables, not for fields — assign the field to a local before checking.
  • Use required named parameters instead of optional positionals so call sites read clearly.

Collections and extensions

final scores = <String, int>{'ada': 91, 'grace': 87, 'linus': 78};

final names = scores.keys.toList()..sort();
final top = scores.entries.reduce((a, b) => a.value >= b.value ? a : b);
final passed = scores.entries.where((e) => e.value >= 80).map((e) => e.key).toList();

extension StringX on String {
  String get titleCase => isEmpty
      ? this
      : '${this[0].toUpperCase()}${substring(1).toLowerCase()}';
}

final spaced = names.map((n) => n.titleCase).join(', ');
print('${top.key} leads with ${top.value}');
print('passed: ${passed.length}, all: $spaced');
OperationResult typeRebuilds lazily?
mapIterableYes
whereIterableYes
toListListNo, materialises
reduceElementNo, throws on empty
foldAccumulatorNo, safe on empty

Futures, streams and async/await

Future<List<String>> loadTags() async {
  await Future<void>.delayed(const Duration(milliseconds: 200));
  return ['flutter', 'dart', 'mobile'];
}

Stream<int> countdown(int from) async* {
  for (var i = from; i > 0; i--) {
    await Future<void>.delayed(const Duration(seconds: 1));
    yield i;
  }
}

Future<void> main() async {
  final tags = await loadTags();
  await for (final value in countdown(3)) {
    print('tick $value (${tags.length} tags loaded)');
  }

  // run several futures together and fail fast on the first error
  final results = await Future.wait([
    loadTags(),
    Future.value(['a']),
  ]);
  print(results.expand((e) => e).toSet());
}
⚠️
Use BuildContext across an await only after checking if (!mounted) return;. The widget may have been disposed while the future was pending, and using a stale context throws at runtime.

FAQ

When should I use a Stream instead of a Future?
A Future resolves once, so use it for a single request or a one-off read. Use a Stream when values arrive over time — sensor data, websocket messages, or database queries that should update the UI on every change.
Why does my <code>setState</code> Say the widget is unmounted?
The async callback completed after the State was disposed. Guard every post-await setState with a mounted check, or cancel the subscription in dispose.

State management: setState, Provider, Riverpod and BLoC Networking, JSON and serialization

Last refreshed 2026-09-18.