lively 1.1.0
lively: ^1.1.0 copied to clipboard
Zero-boilerplate reactive Flutter widgets via code generation.
lively #
Reactive Flutter widgets and stores with zero boilerplate.
Write a plain Dart class. Add @Live() or @LiveStore(). Run build_runner.
That's it — your widget or store is fully reactive.
@Live()
class CounterPage extends _$CounterPage {
int count = 0;
@override
Widget build(BuildContext context) => Scaffold(
body: Center(
child: Column(children: [
Text('$count'),
ElevatedButton(
onPressed: () => count++, // ← just assign, rebuild happens
child: const Text('+'),
),
]),
),
);
}
No setState. No ValueNotifier. No StreamController. No ChangeNotifier boilerplate.
Just a class, a field, and an assignment.
Installation #
# pubspec.yaml
dependencies:
lively: ^1.0.0
dev_dependencies:
lively_generator: ^1.0.0
build_runner: ^2.0.0
Add part 'your_file.g.dart'; to your source file, then run:
dart run build_runner build
# or watch mode:
dart run build_runner watch
What gets generated #
@Live() — reactive widget #
| Generated | Role |
|---|---|
CounterPageWidget |
Thin StatefulWidget — use this in your tree |
_$CounterPage |
Abstract State base with microtask batching |
_CounterPageImpl |
Concrete State that wires reactive setters |
@LiveStore() — reactive store #
| Generated | Role |
|---|---|
_$CartStore |
Abstract ChangeNotifier base with microtask batching |
CartStore |
Concrete class with reactive setters — instantiate this |
You write the logic. The generator writes the boilerplate.
Features #
Reactive fields — automatic setState #
Any mutable field that appears in build() gets a setter override that
schedules a batched rebuild via Future.microtask. Multiple assignments in
the same synchronous block produce exactly one rebuild.
@Live()
class ProfilePage extends _$ProfilePage {
String name = 'Alice';
int age = 30;
@override
Widget build(BuildContext context) => Text('$name, $age');
void birthday() {
name = 'Alice (older)'; // schedules rebuild
age++; // dirty flag already set — no extra rebuild
} // → ONE rebuild fires after the sync block
}
Reactive fields — all mutable fields are wired #
Every mutable field gets a setter that schedules a rebuild. Call notify() when
you need a rebuild that wouldn't happen automatically (e.g. direct mutation of a
private field or an untracked object).
@Live()
class MyPage extends _$MyPage {
int counter = 0; // wired — setter schedules rebuild ✓
String log = ''; // also wired
@override
Widget build(BuildContext context) => Text('$counter');
}
Widget parameters — late final fields #
late final fields without an initializer become constructor parameters
on the generated widget. Non-nullable types become required; nullable types
are optional. When the parent rebuilds with new values, the widget updates
automatically — no manual didUpdateWidget needed.
@Live()
class CounterPage extends _$CounterPage {
late final int initialValue; // required param
late final String? title; // optional param
int count = 0;
@override
void initState() {
super.initState(); // params are ready here
count = initialValue;
}
}
// Usage:
CounterPageWidget(initialValue: 10)
CounterPageWidget(initialValue: 0, title: 'My Counter')
Disposable resources — automatic cleanup #
Known disposable types are detected by type alone — no annotation required.
Nullable fields use ?. calls so null values are safely skipped.
| Type | Cleanup |
|---|---|
TextEditingController |
.dispose() |
AnimationController |
.dispose() |
FocusNode |
.dispose() |
ScrollController |
.dispose() |
PageController |
.dispose() |
StreamController |
.close() |
StreamSubscription |
.cancel() |
@Live()
class FormPage extends _$FormPage {
TextEditingController nameCtrl = TextEditingController();
FocusNode nameFocus = FocusNode();
TextEditingController? optCtrl; // nullable — disposed with ?.dispose()
// ↑ all disposed automatically — no @override dispose() needed
}
ChangeNotifier integration #
Fields whose type extends ChangeNotifier are automatically wired with
addListener / removeListener. When the model calls notifyListeners(), the
widget rebuilds. Three declaration styles are supported:
Mutable field — widget owns the instance; setter re-wires on replacement:
@Live()
class UserPage extends _$UserPage {
UserModel model = UserModel();
// model.rename('Bob') → notifyListeners() → rebuild ✓
// model = UserModel() → old listener removed, new one added ✓
}
late final param — instance injected from outside; didUpdateWidget
re-wires when the parent swaps the store:
@Live()
class UserPage extends _$UserPage {
late final UserModel model; // required constructor param
@override
Widget build(BuildContext context) => Text(model.name);
}
// Usage:
UserPageWidget(model: sharedModel)
late final with initializer — lazily resolved at first access (e.g.
from a service locator); listener wired in initState, removed in dispose:
@Live()
class UserPage extends _$UserPage {
late final UserModel model = GetIt.instance.get<UserModel>();
}
@LiveStore — shareable reactive state #
@LiveStore turns a plain Dart class into a reactive ChangeNotifier with the
same zero-boilerplate experience as @Live(). Use it when state needs to live
outside a single widget — across navigation, shared between siblings, or injected
as a dependency.
Naming convention: prefix your spec class with _. The generator strips the
underscore to produce the public class name, mirroring how @Live() appends
Widget.
// cart_store.dart
part 'cart_store.g.dart';
@LiveStore()
class _CartStore extends _$CartStore {
List<CartItem> items = [];
double get total => items.fold(0.0, (sum, item) => sum + item.price);
void add(CartItem item) => items.add(item);
void remove(CartItem item) => items.remove(item);
void clear() => items.clear();
}
The generator produces:
// cart_store.g.dart — GENERATED
abstract class _$CartStore extends ChangeNotifier {
void _scheduleNotify() { /* Future.microtask batching */ }
void notify() => _scheduleNotify(); // manual escape hatch
@mustCallSuper @override void dispose() => super.dispose();
}
class CartStore extends _CartStore {
late LiveList<CartItem> _$items;
@override List<CartItem> get items => _$items;
@override set items(List<CartItem> v) { _$items = LiveList.of(v, _scheduleNotify); _scheduleNotify(); }
CartStore() {
_$items = LiveList.of(super.items, _scheduleNotify);
}
}
Using a store in a @Live() widget
Since CartStore is a ChangeNotifier, all three @Live() declaration patterns
work automatically.
Owned — widget creates, wires, and disposes the store:
@Live()
class CartPage extends _$CartPage {
// Inline initializer → lively detects ownership →
// adds cart.addListener in initState and cart.dispose() in dispose().
final CartStore cart = CartStore();
@override
void initState() {
super.initState();
cart.addListener(notify); // wire manually when field is `final`
}
@override
void dispose() {
cart.removeListener(notify);
cart.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Text('${cart.items.length} items');
}
Borrowed param — store lives outside, widget borrows it:
@Live()
class CartPage extends _$CartPage {
late final CartStore cart; // required constructor param
// addListener/removeListener/didUpdateWidget auto-generated
// cart.dispose() NOT called — caller owns the store
}
// Usage:
CartPageWidget(cart: sharedCart)
Service-located — lazy resolution, listener wired on first access:
@Live()
class CartPage extends _$CartPage {
late final CartStore cart = GetIt.instance.get<CartStore>();
// addListener in initState, removeListener in dispose — no dispose() call
}
DI constructor params
late final fields without an initializer become named constructor parameters
on the generated store class — the same pattern as widget params with @Live():
@LiveStore()
class _CartStore extends _$CartStore {
late final ApiService api; // required — CartStore({required ApiService api, ...})
late final Logger? logger; // optional
List<CartItem> items = [];
}
final cart = CartStore(api: getIt<ApiService>());
Params are set at the top of the generated constructor, before listener wiring and collection initialisation, so they are safe to access from any field initializer.
Nested stores
A @LiveStore field inside another @LiveStore is auto-wired the same way as
any other ChangeNotifier field. Mutations on the child bubble up through
notifyListeners():
@LiveStore()
class _AppStore extends _$AppStore {
CartStore cart = CartStore(); // owned child store
// → addListener in ctor, removeListener + .dispose() in dispose()
// → cart.items.add(...) → CartStore.notifyListeners() → AppStore._scheduleNotify()
}
File-ordering note
Put @LiveStore classes in a separate file from the @Live() widgets that
reference the generated store class. build_runner generates files in dependency
order; if both are in the same file, the generated store type is not yet visible
when the widget is analysed.
cart_store.dart ← @LiveStore here
cart_page.dart ← import 'cart_store.dart'; @Live here
Deep object reactivity — _Live proxy subclasses #
For plain mutable classes (no annotation needed), lively_generator
generates a _Live<ClassName> proxy subclass that intercepts every field
setter and triggers a rebuild. This works recursively through nested objects.
class Address { String street = '123 Main'; String city = 'Springfield'; }
class User { String name = 'Alice'; int age = 30; Address address = Address(); }
@Live()
class UserPage extends _$UserPage {
User user = User();
@override
Widget build(BuildContext context) => Column(children: [
Text(user.name),
Text(user.address.street),
]);
void move() {
user.name = 'Bob'; // rebuild ✓
user.address.street = '456 Oak Ave'; // rebuild ✓ — deep mutation
}
}
Constraints: the nested class must have a no-arg (or all-optional)
constructor; final / sealed classes fall back to plain reactive setters;
private fields (_) are never proxied.
Reactive collections — LiveList, LiveSet, LiveMap #
List<T>, Set<T>, and Map<K, V> fields are backed by reactive wrappers
that notify on every structural mutation (add, remove, clear, etc.).
When T / V / K is a proxyable class, items are automatically wrapped as
_Live<T> proxies on entry — so field-level mutations on items also trigger
rebuilds with no manual call needed. Works in both @Live() widgets and
@LiveStore classes.
class Car { String make = 'Toyota'; String color = 'white'; }
@Live()
class GaragePage extends _$GaragePage {
List<Car> cars = [];
void addCar() => cars.add(Car()); // structural → rebuild ✓
void paintRed(Car c) => c.color = 'red'; // item mutation → rebuild ✓
void remove(Car c) => cars.remove(c); // structural → rebuild ✓
}
notify() — manual escape hatch #
Every @Live() class and @LiveStore class inherits a notify() method that
manually schedules a rebuild / notifyListeners(). Use it when you mutate state
that isn't tracked automatically.
logEntry = 'updated'; // silent field
notify(); // explicit rebuild / notifyListeners()
final fields — non-reactive constants #
Fields declared final with an initializer are passed through unchanged — no
setter, no backing field, no rebuild.
@Live()
class MyPage extends _$MyPage {
final String appName = 'My App'; // plain constant, no codegen
}
Field classification — quick reference #
@Live() widget fields #
| Declaration | Classification | Effect |
|---|---|---|
late final T x (no init) |
Widget param | Required constructor arg; set before initState |
late final T? x (nullable, no init) |
Widget param | Optional constructor arg |
late final T x where T extends CN (no init) |
Widget param + listener | Param and addListener; didUpdateWidget re-wires on swap |
late final T x = expr where T extends CN |
Listener only | addListener in initState; no param, no setter |
final T x = v |
Constant | No codegen |
TextEditingController x |
Disposable | Auto-disposed in dispose() |
T x where T is @LiveStore with init |
Owned store | Listener + dispose() in dispose() |
T x where T extends ChangeNotifier |
ChangeNotifier | Listener wired; setter re-wires on replacement |
T x where T is a proxyable class |
Proxy object | _LiveT subclass generated |
List<T> x / Set<T> x / Map<K,V> x |
Collection | Backed by LiveList / LiveSet / LiveMap |
T x (primitive / other) |
Reactive scalar | Setter always calls _scheduleRebuild() |
@LiveStore class fields #
Same classification rules apply, with these differences:
| Declaration | Classification | Effect |
|---|---|---|
late final T x (no init, not CN) |
Constructor param | CartStore({required T x}) |
late final T x (no init, T extends CN) |
Constructor param + listener | Param + addListener in constructor |
late final T x = expr (T extends CN) |
Listener only | addListener in constructor |
T x where T is @LiveStore with init |
Owned store | Listener + dispose() in dispose() |
| Everything else | Same as @Live() |
— |
Design philosophy #
- Two annotations.
@Live()for widget-local state,@LiveStore()for shareable state. No other annotations needed. - Zero runtime dependencies. Pure Flutter + Dart — no rxdart, no GetX, no Provider.
- Zero new concepts. Fields are fields. Assignments are assignments. The generator handles everything else.
- Microtask batching. Multiple field mutations in one synchronous block produce exactly one rebuild /
notifyListeners(), with no arbitrary delay. - Predictable rebuilds. Every mutable field is wired — no silent non-rebuilds from helper-method blind spots. Use
notify()for the rare case where you need manual control. - Keep widgets small. Full widget rebuilds are cheap when widgets are focused. Split large widgets rather than reaching for granular rebuild primitives.
Comparison with other approaches #
Scope #
Lively covers both local widget state (@Live()) and shareable reactive
state (@LiveStore()). It does not provide dependency injection or a global
store container — use Riverpod or Provider on top for the injection layer if you
need it. They compose naturally.
Feature matrix #
| setState | ChangeNotifier + Provider | Riverpod | Bloc / Cubit | MobX | GetX | Lively | |
|---|---|---|---|---|---|---|---|
| Setup | none | package | package | package | package + codegen | package | package + codegen |
| New concepts to learn | none | notifyListeners, Consumer |
providers, ref, AsyncValue |
events, states, streams | @observable, @action, Observer |
.obs, Obx |
none |
| Code generation | — | — | optional | — | required | — | required |
| Local widget state | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Shared / global state | ✗ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Computed / derived values | ✗ | ✗ | ✓ | ✗ | ✓ | ✓ | ✗ |
| Deep object reactivity | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✓ |
| Auto-dispose controllers | ✗ | ✗ | ✓ | ✗ | ✗ | ✗ | ✓ |
| Batched rebuilds | ✗ | ✗ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Runtime Flutter dependency | Flutter | Provider | Riverpod | flutter_bloc | mobx | get | Flutter only |
Side-by-side: a counter with two fields #
Plain setState
class _CounterState extends State<CounterPage> {
int count = 0;
String label = 'hits';
@override
Widget build(BuildContext context) => Column(children: [
Text('$count $label'),
ElevatedButton(
onPressed: () => setState(() { count++; }),
child: const Text('+'),
),
]);
}
ChangeNotifier + Provider
class CounterModel extends ChangeNotifier {
int count = 0;
String label = 'hits';
void increment() { count++; notifyListeners(); }
}
// in widget tree — must be provided above this widget
class CounterPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final m = context.watch<CounterModel>();
return Column(children: [
Text('${m.count} ${m.label}'),
ElevatedButton(onPressed: context.read<CounterModel>().increment,
child: const Text('+')),
]);
}
}
MobX (closest conceptual alternative)
part 'counter.g.dart';
class CounterStore = _CounterStore with _$CounterStore;
abstract class _CounterStore with Store {
@observable int count = 0;
@observable String label = 'hits';
@action void increment() => count++;
}
// in widget:
class CounterPage extends StatelessWidget {
final store = CounterStore();
@override
Widget build(BuildContext context) => Observer(
builder: (_) => Column(children: [
Text('${store.count} ${store.label}'),
ElevatedButton(onPressed: store.increment, child: const Text('+')),
]),
);
}
Lively
@Live()
class CounterPage extends _$CounterPage {
int count = 0;
String label = 'hits';
@override
Widget build(BuildContext context) => Column(children: [
Text('$count $label'),
ElevatedButton(
onPressed: () => count++,
child: const Text('+'),
),
]);
}
When to use Lively #
Good fit:
- Form pages, detail screens, local UI state (loading flags, tab selection, input values).
- Widgets that own their state entirely — nothing needs to be shared.
- Shared state objects (
@LiveStore) passed to multiple widgets via constructor params or a service locator. - Teams that want plain Dart classes without learning a reactive framework.
- Projects where you already use
build_runner(e.g. for JSON serialisation).
Not a good fit:
- You need computed/derived values that update automatically (
@computedin MobX,Provider.select,ref.watchselectors in Riverpod). - You need time-travel debugging or strict audit trails of state changes (Bloc is better here).
- You want to avoid a build step entirely (GetX or plain
ValueNotifierhave no codegen).
Lively vs MobX — the closest comparison #
Both use build_runner. The key differences:
| MobX | Lively | |
|---|---|---|
| Store / widget separation | always split — store class required | optional — @Live() embeds state in the widget; @LiveStore() extracts it |
| Widget wrapping needed | Observer(builder: ...) wraps every reactive subtree |
plain build() — no wrapper |
| Granularity | rebuilds only the Observer that accessed the changed value |
rebuilds the whole widget (use Reactive to opt in to subtree granularity) |
| Auto-dispose | no | yes — controllers disposed by type detection |
| Deep object reactivity | no | yes — _Live<T> proxies intercept nested field setters |
| Computed values | yes — @computed |
no |
| Concepts to learn | @observable, @action, @computed, Observer, reaction, autorun |
none |
MobX is the right choice when you need fine-grained Observer subtrees or
computed values. Lively is the right choice when you want plain field assignments
with no ceremony, and optionally shareable store objects without learning a new
reactive model.
The Reactive wrapper — surgical opt-in #
When profiling reveals a genuine performance bottleneck, use the Reactive
widget to rebuild only a subtree when specific values change.
Reactive(
watch: [counter],
builder: () => Text('$counter'),
)
This is rarely needed. Most widgets are fast enough with plain setState.
Related tools #
If your Flutter app communicates with a GraphQL API, check out
GraphLink — a code generator that reads
a .graphql schema and produces fully-typed Dart/Flutter clients (and
optionally Java or Spring Boot server stubs) with zero runtime dependency.
Works well alongside Lively: generate your GraphQL layer once with glink,
then use Lively for the rest of your app's code generation needs.
GraphQL user? Pair Lively with GraphLink to auto-generate your typed Flutter GraphQL client from a
.graphqlschema.
- pub.dev: https://pub.dev/packages/graphlink
- GitHub: https://github.com/Oualitsen/graphlink
- Docs: https://graphlink.dev/docs/index.html
AI Agent Usage Guide #
This section is written for AI agents (Copilot, Claude, Cursor, etc.) generating or assisting with
lively-based Flutter code. Read it fully before producing any code. The rules are strict and order-dependent.
Setup #
User's pubspec.yaml:
dependencies:
lively: ^1.0.0
dev_dependencies:
lively_generator: ^1.0.0
build_runner: ^2.0.0
Every source file using these annotations must declare a part file:
part 'my_file.g.dart';
Run generation:
dart run build_runner build
# or watch mode:
dart run build_runner watch
@Live() — Reactive Widgets #
Minimal example
// counter_page.dart
part 'counter_page.g.dart';
@Live()
class CounterPage extends _$CounterPage {
int count = 0;
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () => count++, // triggers rebuild automatically
child: Text('$count'),
);
}
}
Usage in widget tree:
CounterPageWidget() // generated name = class name + "Widget"
Naming rule
| User writes | Generated widget name |
|---|---|
class FooPage extends _$FooPage |
FooPageWidget |
class MyScreen extends _$MyScreen |
MyScreenWidget |
The user class always extends _$ClassName (leading _$). The generated file provides that abstract base.
Field classification — @Live() #
First-match-wins order. Apply top to bottom.
| Priority | Field declaration | Behavior |
|---|---|---|
| 1 | late final T x (no init, T not CN, not disposable) |
Widget constructor param |
| 2 | late final T x (no init, T extends ChangeNotifier) |
Widget param + CN listener + didUpdateWidget |
| 3 | late final T x = expr (T extends ChangeNotifier) |
CN listener only — no param, no setter |
| 4 | final T x = expr (not late) |
Non-reactive constant — no codegen |
| 5 | Disposable type with initializer | Auto-disposed in dispose() |
| 6 | ChangeNotifier subclass (mutable field, has init) | Listener wired; setter re-wires on replacement |
| 7 | Plain user-defined class (mutable) | _Live<ClassName> proxy generated — deep reactivity |
| 8 | List<T> |
Backed by LiveList<T> |
| 9 | Set<T> |
Backed by LiveSet<T> |
| 10 | Map<K, V> |
Backed by LiveMap<K, V> |
| 11 | Everything else mutable | Reactive scalar — setter calls _scheduleRebuild() |
Disposable types (checked before ChangeNotifier):
TextEditingController→.dispose()AnimationController→.dispose()FocusNode→.dispose()StreamController→.close()- Other known disposables →
.dispose()or.close()or.cancel()depending on type
Widget constructor params — late final fields #
late final without an initializer = widget parameter. Params are set before initState() runs.
@Live()
class ProfilePage extends _$ProfilePage {
late final String title; // required param (non-nullable)
late final int? subtitle; // optional param (nullable → defaults to null)
int count = 0;
@override
void initState() {
super.initState();
// title and subtitle are already set here — safe to read
}
}
Generated widget:
ProfilePageWidget(title: 'Hello') // subtitle omitted — OK
ProfilePageWidget(title: 'Hello', subtitle: 42)
Rules:
- Non-nullable
late final T x→requirednamed param - Nullable
late final T? x→ optional named param (norequired) late final T x = expr(has initializer) → NOT a param; treated as a non-reactive constant
Reactive scalars #
Every mutable non-final field becomes reactive. All setters call _scheduleRebuild() unconditionally.
String name = 'John'; // setter generated → any assignment triggers rebuild
int age = 25;
bool loading = false;
final String appTitle = 'My App'; // final → NO setter, NO reactivity
Batching: Multiple assignments in one sync block schedule exactly one rebuild via Future.microtask().
@LiveStore() — Reactive Stores #
Naming convention — CRITICAL
The user writes the class with a leading underscore. The generator strips it to produce the public name.
| User writes | Generated public class |
|---|---|
class _UserStore extends _$UserStore |
UserStore |
class _AppStore extends _$AppStore |
AppStore |
// user_store.dart
part 'user_store.g.dart';
@LiveStore()
class _UserStore extends _$UserStore {
String name = 'Alice';
int age = 30;
}
// Usage:
final store = UserStore();
store.name = 'Bob'; // triggers notifyListeners() (batched)
Field classification — @LiveStore()
Same first-match-wins order as @Live(), with these differences:
| Field | @Live() | @LiveStore() |
|---|---|---|
late final T x (no init, not CN) |
Widget param | Constructor param |
late final T x (no init, T extends CN) |
Widget param + listener + didUpdateWidget |
Constructor param + listener (no didUpdateWidget) |
| Everything else | Same | Same |
Constructor params in stores
@LiveStore()
class _UserStore extends _$UserStore {
late final ApiService api; // required: UserStore(api: ...)
late final LogService? logger; // optional: UserStore(logger: ...)
late final SettingsStore settings; // required + CN listener wired
}
Params are set at the TOP of the generated constructor body, before any listener wiring or collection init.
Owned vs. borrowed @LiveStore fields
| Declaration | Ownership | dispose() behavior |
|---|---|---|
CartStore cart = CartStore() |
Owned (has inline init) | removeListener + cart.dispose() |
late final SettingsStore s (param) |
Borrowed | removeListener only |
late final SettingsStore s = GetIt.instance.get() |
Borrowed | removeListener only |
ChangeNotifier integration — three patterns #
Pattern 1 — owned mutable field:
UserModel user = UserModel();
// addListener in initState; setter re-wires; removeListener + NOT .dispose() in dispose()
Pattern 2 — borrowed param (late final, no init):
late final UserModel store;
// widget constructor param; addListener in initState; didUpdateWidget re-wires; removeListener in dispose
Pattern 3 — service-located (late final with init):
late final UserModel store = GetIt.instance.get<UserModel>();
// addListener in initState; removeListener in dispose; no param, no setter
Proxy objects — deep reactivity #
When a mutable field's type is a plain user-defined class, the generator produces a _Live<ClassName> subclass.
Proxy constraints — when proxy is NOT generated:
- Class is
finalorsealed - Class has no no-arg (or all-optional) constructor
- Private fields (
_field) on nested classes finalfields on nested classes- Cycles (
A → B → A) — second encounter treated as a leaf
When proxy cannot be generated, the field falls back to plain reactive scalar.
Common mistakes to avoid #
-
Wrong base class.
@Live()→extends _$ClassName.@LiveStore()→ user class has a_prefix:class _StoreName extends _$StoreName. -
Missing
partdirective. Every file using these annotations needspart 'filename.g.dart';at the top. -
Using
finalwhen you want a param.final String title = 'x'is a non-reactive constant. Uselate final String title;(no initializer) for a param. -
Expecting deep reactivity without proxy support. If the nested class is
final,sealed, has no accessible default constructor, or uses private fields — only reference replacement triggers rebuilds. -
Using identity-based objects as Map keys.
Map<Car, String>whereCardoesn't override==/hashCodewill break after key wrapping. -
Calling
.dispose()on a borrowed ChangeNotifier.livelydoes NOT call.dispose()on borrowed CNs. Only owned CNs (with inline initializer) are auto-disposed. -
Expecting
notify()outside the class.notify()is a protected escape hatch — call it from inside the user class only.
Quick decision table for field declarations #
| I want... | Write this |
|---|---|
| A required widget param | late final String title; |
| An optional widget param | late final String? title; |
| A reactive field | String name = 'John'; |
| A non-reactive constant | final String appTitle = 'App'; |
| An auto-disposed controller | TextEditingController ctrl = TextEditingController(); |
| A reactive list | List<String> items = []; |
| A reactive map | Map<String, User> users = {}; |
| Borrowed ChangeNotifier param | late final MyStore store; |
| Owned ChangeNotifier | MyStore store = MyStore(); |
| Service-located store | late final MyStore store = GetIt.instance.get(); |
| Force a rebuild manually | Call notify() inside the class |