Object Initialization in Dart & Flutter
← All Articles

Object Initialization in Dart & Flutter

Constructors, initializer lists, factories, late, and the Flutter State lifecycle — distilled from the official Dart and Flutter documentation.

In this article:

  • How a Dart constructor actually executes — initializer list → super → body
  • Why const objects are canonicalized and what that costs
  • When factory earns its keep (cache, polymorphic creation, async-impossible cases)
  • late, lazy initialization, and the trade-off with immutability
  • Singletons in Dart without third-party packages
  • Where initState, didChangeDependencies, and dispose fit into Flutter's State lifecycle

#Generative Constructors

The most common constructor in Dart is a generative constructor: it produces a brand-new instance every time you call it. Dart gives it a few syntactic shortcuts that the official docs call out specifically.

class Point {
  final double x;
  final double y;

  // Long form
  Point(double x, double y) : x = x, y = y;

  // Idiomatic — "initializing formal parameter" (this.x)
  Point.same(this.x, this.y);

  // Named constructor
  Point.origin() : x = 0, y = 0;

  // Redirecting constructor — delegates to another one
  Point.zero() : this.origin();
}

Three forms matter day to day:

  • Initializing formal parameters (this.x) assign a constructor argument directly to a field before the body runs. They are required for final fields.
  • Named constructors exist purely to make call sites readable — Point.origin(), Duration.zero(), Color.fromARGB(…).
  • Redirecting constructors let one constructor forward to another with no body of their own.

Execution order

A subtle but important rule from the Dart spec: a generative constructor runs in three phases.

  1. Initializer list — runs first, in order, with no access to this. Final fields must be set here.
  2. Superclass constructor — invoked at the end of the initializer list (implicitly super() if you don't write one).
  3. Constructor body — runs last; this is fully formed and final fields are already assigned.
If a final field is not assigned in the initializer list (or via an initializing formal parameter), the analyzer rejects the class. The body cannot fix it — it runs after super, and finals are already locked.

#Initializer Lists vs the Constructor Body

The initializer list is the only place where you can:

  • Assign final fields.
  • Run assert checks before this exists.
  • Compute derived values that depend on constructor arguments.
  • Pass arguments to super with computed values.
class Rect {
  final double width;
  final double height;
  final double area;       // derived
  final double diagonal;   // derived

  Rect(this.width, this.height)
      : assert(width > 0 && height > 0, 'sides must be positive'),
        area = width * height,
        diagonal = _hypot(width, height);

  static double _hypot(double a, double b) =>
      math.sqrt(a * a + b * b);
}

Helper methods called from the initializer list must be static — there's no this yet.


#const Constructors and Canonicalization

A const constructor produces a compile-time constant. The compiler computes the value at compile time and canonicalizes it: every const Point(0, 0) in your program is the same object instance.

class Color {
  final int value;
  const Color(this.value);
}

const a = Color(0xFF112233);
const b = Color(0xFF112233);
identical(a, b); // true — canonicalized

final c = Color(0xFF112233);
final d = Color(0xFF112233);
identical(c, d); // false — `final`, not `const`

The rules are strict for a reason. To be const:

  • All fields must be final.
  • The initializer list may only contain other compile-time constants.
  • The class cannot extend anything other than Object unless its supertype also has a const constructor.
  • The constructor body must be empty (no body at all).

In Flutter this matters in two visible ways. First, const widgets are skipped during rebuilds — the framework can compare them by identity. Second, const objects live in the read-only segment of memory; they cost zero allocations to "create".


#Factory Constructors

A factory constructor is not required to return a freshly allocated instance. It returns an existing one, a subtype, or whatever object the implementation builds.

class Logger {
  static final _cache = <String, Logger>{};

  final String name;
  Logger._internal(this.name);

  factory Logger(String name) {
    return _cache.putIfAbsent(name, () => Logger._internal(name));
  }
}

final a = Logger('net');
final b = Logger('net');
identical(a, b); // true — same cached instance

Use factory when the act of "construction" is really a lookup, a parse, or a polymorphic decision:

  • Caching — Logger / Symbol-style flyweights.
  • Returning a subtype — e.g. Iterable.from() returns one of several private classes.
  • Parsingint.parse('42'), Uri.parse('…').

Limitations to remember:

  • No access to this inside the factory body.
  • A factory cannot be const unless it redirects to another const constructor.
  • A factory cannot run async code — it must return synchronously. For async creation use a static method (static Future<X> create(…)).

#late & Lazy Initialization

The late modifier delays field initialization in two ways:

class UserService {
  // (1) `late` without an initializer — must be assigned before first read.
  late final Database db;

  // (2) `late` with an initializer — the expression runs the first
  //     time the field is read, not when the object is constructed.
  late final ApiClient api = ApiClient(baseUrl: Config.url);
}

Form (1) is what enables non-nullable fields that are wired up later — for example assigning db from the result of an async call in initState. Reading an unassigned late field throws LateInitializationError at runtime; the analyzer cannot catch it.

Form (2) is true lazy initialization: the right-hand side runs on first access. This is the idiomatic pattern for an expensive object you may never use.

The Dart docs are explicit: late final with an initializer is the only form guaranteed to run the initializer at most once and to be safe across re-entrant access. Plain late (no initializer) gives no such guarantee.

#Singletons Without External Packages

Two idiomatic forms cover almost every case. No third-party container required.

Eager singleton via a static const constructor — created once, immutable, free of cost.

class Config {
  final String apiUrl;
  final Duration timeout;
  const Config._(this.apiUrl, this.timeout);

  static const Config dev = Config._('https://dev.api', Duration(seconds: 5));
  static const Config prod = Config._('https://api', Duration(seconds: 15));
}

Lazy singleton via a factory and a private constructor — created on first use.

class AnalyticsClient {
  AnalyticsClient._();
  static final AnalyticsClient instance = AnalyticsClient._();
}

// Usage
AnalyticsClient.instance.track('open');

The static final field is itself lazy in Dart — the constructor runs only when instance is read for the first time. No locks, no double-checked-locking dance: there is only one isolate.


#Flutter Widget Lifecycle

For StatelessWidget initialization is trivial — the constructor runs and the widget is rebuilt as the parent does. StatefulWidget introduces a separate State object, and that's where most lifecycle work happens.

StatefulWidget                State<T>
─────────────                 ──────────────────────────
createState()  ────────────→  constructor
                              initState()                ← once, after mounting
                              didChangeDependencies()    ← after initState + when deps change
                              build()                    ← every rebuild
                              didUpdateWidget(old)       ← when parent rebuilds with new config
                              setState() → build()
                              deactivate() → dispose()   ← when removed from the tree

What goes where, per the Flutter API docs:

  • initState — one-shot setup that does not need InheritedWidget data: subscribing to a stream, creating an AnimationController, kicking off an async load.
  • didChangeDependencies — anything that depends on inherited data (Theme.of(context), your own InheritedWidget). It runs after initState and again whenever those dependencies change.
  • build — pure function of state. No side effects, no I/O, no timers.
  • didUpdateWidget — react when the parent gives you a new widget with different fields (e.g. cancel and re-subscribe to the new stream).
  • dispose — mirror of initState: cancel subscriptions, dispose controllers, free buffers. Always call super.dispose() last.
class _FeedState extends State<Feed> {
  late final StreamSubscription<Event> _sub;
  late final AnimationController _anim;

  @override
  void initState() {
    super.initState();
    _anim = AnimationController(vsync: this, duration: kAnimD);
    _sub = widget.bus.events.listen(_onEvent);
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // Theme / InheritedWidget data is now reachable via context.
  }

  @override
  void dispose() {
    _sub.cancel();
    _anim.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => ...;
}
Never call setState from initState, build, or dispose. The framework asserts it in debug mode for a reason — those calls are part of the build pipeline itself.

#What's Next

With the language-level pieces in place — generative vs factory constructors, const, late, and the State lifecycle — the next question is architectural: how do these objects find each other?

In Part 2 we apply exactly the same primitives — constructors with explicit parameters, an InheritedWidget, and a step-by-step initializer — to build a dependency-injection layer that needs no external packages and no service-locator magic.