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 |