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à value di tipo T.
  • ChangeNotifier: implementazione concreta di Listenable che gestisce la lista di listener e offre notifyListeners().

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:

  1. Esporre viste non modificabili (Map.unmodifiable) evita che la UI alteri lo stato aggirando i metodi.
  2. 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 uno State va 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 con WidgetsBinding.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 ChangeNotifier come 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.