Perché partire da ciò che Flutter offre già
Quando si parla di state management in Flutter si pensa subito a Riverpod, BLoC o Signals. Eppure il framework include da sempre un sistema di reattività semplice e collaudato, basato sull'interfaccia Listenable: è lo stesso meccanismo che fa funzionare TextEditingController, ScrollController, AnimationController e PageController.
Per app di piccole e medie dimensioni, per widget riutilizzabili o per package che non vogliono imporre dipendenze, questa infrastruttura è spesso più che sufficiente. Conoscerla bene aiuta anche a capire come funzionano "sotto il cofano" le librerie più note: ChangeNotifierProvider di Provider, per esempio, è costruito esattamente su questi mattoni.
Il contratto: Listenable
Listenable è un'interfaccia minimale con due metodi:
abstract class Listenable {
void addListener(VoidCallback listener);
void removeListener(VoidCallback listener);
}
Chi ascolta viene notificato che "qualcosa è cambiato", senza ricevere alcun valore. Da qui derivano due specializzazioni fondamentali:
ValueListenable<T>: espone anche una proprietàvaluedi tipoT.ChangeNotifier: implementazione concreta diListenableche gestisce la lista di listener e offrenotifyListeners().
ValueNotifier: il caso più semplice
ValueNotifier<T> è un contenitore di un singolo valore che notifica quando il valore cambia.
final counter = ValueNotifier<int>(0);
counter.value++; // notifica tutti i listener
Nella UI si usa ValueListenableBuilder, che ricostruisce solo il sottoalbero passato nel builder:
class CounterView extends StatefulWidget {
const CounterView({super.key});
@override
State<CounterView> createState() => _CounterViewState();
}
class _CounterViewState extends State<CounterView> {
final _counter = ValueNotifier<int>(0);
@override
void dispose() {
_counter.dispose(); // fondamentale
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: ValueListenableBuilder<int>(
valueListenable: _counter,
builder: (context, value, child) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
child!, // non viene ricostruito
Text('$value', style: Theme.of(context).textTheme.displayMedium),
],
);
},
child: const Text('Valore corrente'),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _counter.value++,
child: const Icon(Icons.add),
),
);
}
}
Il parametro child è il dettaglio che molti ignorano: tutto ciò che non dipende dal valore va messo lì, così viene costruito una sola volta e riutilizzato a ogni notifica.
Attenzione all'uguaglianza
ValueNotifier notifica solo se il nuovo valore è diverso secondo ==. Con oggetti mutabili questo è un problema classico:
final items = ValueNotifier<List<String>>([]);
// NON notifica: è sempre la stessa istanza di lista
items.value.add('nuovo');
// Corretto: nuova istanza immutabile
items.value = [...items.value, 'nuovo'];
La regola è semplice: usa ValueNotifier con tipi immutabili (primitivi, enum, classi con == basato sui valori, per esempio generate con Freezed o equatable).
ChangeNotifier: stato composto e logica di dominio
Quando lo stato ha più campi e qualche metodo di business, ChangeNotifier è la scelta naturale. L'idea è incapsulare i dati e chiamare notifyListeners() quando cambiano.
class CartController extends ChangeNotifier {
final Map<String, int> _quantities = {};
Map<String, int> get quantities => Map.unmodifiable(_quantities);
int get totalItems =>
_quantities.values.fold(0, (sum, q) => sum + q);
bool get isEmpty => _quantities.isEmpty;
void add(String productId) {
_quantities.update(productId, (q) => q + 1, ifAbsent: () => 1);
notifyListeners();
}
void remove(String productId) {
final current = _quantities[productId];
if (current == null) return;
if (current <= 1) {
_quantities.remove(productId);
} else {
_quantities[productId] = current - 1;
}
notifyListeners();
}
void clear() {
if (_quantities.isEmpty) return;
_quantities.clear();
notifyListeners();
}
}
Due accortezze già visibili nell'esempio:
- Esporre viste non modificabili (
Map.unmodifiable) evita che la UI alteri lo stato aggirando i metodi. - Non notificare se nulla è cambiato: ogni
notifyListeners()superfluo è un rebuild sprecato.
ListenableBuilder
Da Flutter 3.10 esiste ListenableBuilder, la versione generica di ValueListenableBuilder: accetta qualsiasi Listenable (quindi anche un ChangeNotifier) e ricostruisce il builder a ogni notifica.
ListenableBuilder(
listenable: cartController,
builder: (context, child) {
return Badge(
label: Text('${cartController.totalItems}'),
child: child,
);
},
child: const Icon(Icons.shopping_cart),
)
In passato si usava AnimatedBuilder anche per oggetti non animati: funziona ancora (è la stessa implementazione), ma ListenableBuilder comunica molto meglio l'intento.
Combinare più sorgenti con Listenable.merge
final combined = Listenable.merge([cartController, userController]);
ListenableBuilder(
listenable: combined,
builder: (context, _) => CheckoutSummary(
user: userController.user,
total: cartController.totalItems,
),
);
Utile quando una porzione di UI dipende da due controller indipendenti. Ricorda che Listenable.merge non va messo dentro build() senza cautela: creandolo a ogni rebuild si aggiungono e rimuovono listener continuamente. Meglio costruirlo in initState (e non serve farne dispose: non possiede i figli).
Stato asincrono: caricamento, errore, dati
Il pattern si adatta bene anche al caricamento dati, soprattutto combinato con le sealed class di Dart 3.
sealed class ArticlesState {
const ArticlesState();
}
class ArticlesLoading extends ArticlesState {
const ArticlesLoading();
}
class ArticlesError extends ArticlesState {
const ArticlesError(this.message);
final String message;
}
class ArticlesData extends ArticlesState {
const ArticlesData(this.items);
final List<Article> items;
}
class ArticlesController extends ValueNotifier<ArticlesState> {
ArticlesController(this._repository) : super(const ArticlesLoading());
final ArticleRepository _repository;
bool _disposed = false;
Future<void> load() async {
value = const ArticlesLoading();
try {
final items = await _repository.fetchAll();
if (_disposed) return;
value = ArticlesData(items);
} catch (e) {
if (_disposed) return;
value = ArticlesError(e.toString());
}
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
Nella UI, lo switch esaustivo sui pattern rende impossibile dimenticare un caso:
ValueListenableBuilder<ArticlesState>(
valueListenable: controller,
builder: (context, state, _) => switch (state) {
ArticlesLoading() => const Center(child: CircularProgressIndicator()),
ArticlesError(:final message) => ErrorView(message: message),
ArticlesData(:final items) => ArticleList(items: items),
},
)
Nota il flag _disposed: se l'utente lascia la schermata mentre la richiesta è in volo, assegnare value dopo il dispose() lancia un'eccezione (A ValueNotifier<...> was used after being disposed).
Propagare il controller nell'albero
Senza pacchetti, la via idiomatica è InheritedNotifier, che unisce la propagazione di InheritedWidget all'ascolto automatico di un Listenable:
class CartScope extends InheritedNotifier<CartController> {
const CartScope({
super.key,
required CartController controller,
required super.child,
}) : super(notifier: controller);
static CartController of(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<CartScope>();
assert(scope != null, 'CartScope non trovato nell\'albero');
return scope!.notifier!;
}
}
I widget che usano CartScope.of(context) si ricostruiscono automaticamente a ogni notifyListeners(). Per un controllo più fine sui rebuild, si può esporre il controller senza dipendenza (con getElementForInheritedWidgetOfExactType) e usare ListenableBuilder solo dove serve.
Rebuild chirurgici
Il punto di forza di questo approccio è che non serve setState sull'intera schermata: il builder ricostruisce solo il sottoalbero interessato. Per ridurre ulteriormente i rebuild quando un ChangeNotifier ha molti campi, si possono esporre più ValueNotifier granulari:
class FilterController {
final query = ValueNotifier<String>('');
final onlyFavorites = ValueNotifier<bool>(false);
final sort = ValueNotifier<SortOrder>(SortOrder.recent);
void dispose() {
query.dispose();
onlyFavorites.dispose();
sort.dispose();
}
}
Così la checkbox dei preferiti non si ricostruisce quando cambia il testo di ricerca. È lo stesso principio del "select" delle librerie di state management, ottenuto a mano.
Un'alternativa elegante è esporre pubblicamente solo il tipo ValueListenable<T>, mantenendo privato il notifier scrivibile:
class SessionController {
final _user = ValueNotifier<User?>(null);
ValueListenable<User?> get user => _user; // sola lettura per la UI
}
Testare i notifier
Un vantaggio enorme: sono semplici oggetti Dart, testabili senza WidgetTester.
test('add incrementa la quantità e notifica', () {
final cart = CartController();
var notifications = 0;
cart.addListener(() => notifications++);
cart.add('p1');
cart.add('p1');
expect(cart.totalItems, 2);
expect(notifications, 2);
});
Per i widget test, pumpWidget più tester.pump() dopo l'azione è sufficiente, perché le notifiche sono sincrone.
Errori comuni da evitare
- Dimenticare
dispose(): ogni notifier creato in unoStateva distrutto, altrimenti i listener restano agganciati e si creano memory leak. In debug Flutter segnala l'uso dopo il dispose, ma non il leak. - Chiamare
notifyListeners()durante il build: provoca l'eccezione "setState() or markNeedsBuild() called during build". Se è inevitabile, rimanda conWidgetsBinding.instance.addPostFrameCallback. - Notificare troppo: un
notifyListeners()per ogni campo modificato in un ciclo genera N rebuild. Raggruppa le modifiche e notifica una volta sola. - Stato mutabile condiviso: esporre liste modificabili rende imprevedibili sia la UI sia i test.
- Usare
ChangeNotifiercome singleton globale senza ciclo di vita: va bene per sessioni e configurazioni, ma per lo stato di una schermata il controller deve nascere e morire con essa.
Quando questo approccio basta (e quando no)
È più che sufficiente quando:
- lo stato è locale a una schermata o a un flusso;
- stai scrivendo un package e vuoi zero dipendenze;
- il team è piccolo e preferisci API del framework a convenzioni esterne.
Conviene invece valutare soluzioni più strutturate quando servono dependency injection con scope complessi, invalidazione automatica e caching delle richieste, composizione di molti stati derivati, o strumenti di debug e time-travel.
La buona notizia è che la migrazione è quasi indolore: un ChangeNotifier ben scritto può essere avvolto in un ChangeNotifierProvider, esposto da un provider Riverpod o sostituito da un Cubit con modifiche minime, perché la logica di dominio resta separata dalla UI. Partire da ValueNotifier e ListenableBuilder è quindi una scelta pragmatica: meno dipendenze oggi, nessun vincolo domani.