In this article:
- How a Dart constructor actually executes — initializer list → super → body
- Why
constobjects are canonicalized and what that costs - When
factoryearns 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, anddisposefit into Flutter'sStatelifecycle
#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 forfinalfields. - 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.
- Initializer list — runs first, in order, with no access to
this. Final fields must be set here. - Superclass constructor — invoked at the end of the initializer list (implicitly
super()if you don't write one). - Constructor body — runs last;
thisis 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
finalfields. - Run
assertchecks beforethisexists. - Compute derived values that depend on constructor arguments.
- Pass arguments to
superwith 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
Objectunless its supertype also has aconstconstructor. - 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. - Parsing —
int.parse('42'),Uri.parse('…').
Limitations to remember:
- No access to
thisinside the factory body. - A factory cannot be
constunless it redirects to another const constructor. - A factory cannot run
asynccode — 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 finalwith an initializer is the only form guaranteed to run the initializer at most once and to be safe across re-entrant access. Plainlate(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 needInheritedWidgetdata: subscribing to a stream, creating anAnimationController, kicking off an async load.didChangeDependencies— anything that depends on inherited data (Theme.of(context), your ownInheritedWidget). It runs afterinitStateand 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 newwidgetwith different fields (e.g. cancel and re-subscribe to the new stream).dispose— mirror ofinitState: cancel subscriptions, dispose controllers, free buffers. Always callsuper.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 callsetStatefrominitState,build, ordispose. 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.