Stato reattivo senza package in Flutter con ValueNotifier e ValueListenableBuilder

Foto di Annie Spratt su Unsplash

GuidePrincipiante30 min Flutter 3.x

Stato reattivo senza package in Flutter con ValueNotifier e ValueListenableBuilder

Quando si inizia con Flutter il primo strumento che si impara per aggiornare l'interfaccia è setState. Funziona, ma ha un limite: ricostruisce l'intero widget che lo contiene, anche le parti che non sono cambiate.

Prima di installare Provider, Riverpod o BLoC, vale la pena conoscere una coppia di classi che Flutter mette a disposizione out of the box:

  • ValueNotifier<T>: un contenitore di un valore che avvisa i suoi ascoltatori quando il valore cambia;
  • ValueListenableBuilder<T>: un widget che ascolta un ValueNotifier e ricostruisce solo il suo sottoalbero.

Sono leggerissimi, non richiedono dipendenze esterne e sono perfetti per lo stato locale di una schermata: un contatore, un interruttore, una lista di elementi, lo stato di caricamento di un pulsante.

In questo tutorial costruiremo passo passo una piccola app "Lista della spesa" che usa solo ValueNotifier, imparando anche gli errori classici (dimenticare dispose, mutare una lista senza notificare) e come ottimizzare i rebuild.

Prerequisiti: Flutter installato, conoscenza di base di StatefulWidget e setState.

  1. 1

    Creare il progetto e capire il limite di setState

    Crea un nuovo progetto e sostituisci il contenuto di lib/main.dart con una schermata minimale.

    Nell'esempio qui sotto usiamo setState in modo classico: ogni tocco sul pulsante ricostruisce tutto il build della schermata, compreso il testo "Intestazione statica" che non cambia mai.

    Aggiungi la print dentro build: quando premi il pulsante vedrai nel terminale che il metodo viene eseguito ogni volta. Con pochi widget non è un problema, ma in una schermata complessa è lavoro sprecato.

    Tieni a mente questa riga di log: nei prossimi passi la faremo sparire.

    import 'package:flutter/material.dart';
    
    void main() => runApp(const MyApp());
    
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Demo ValueNotifier',
          theme: ThemeData(colorSchemeSeed: Colors.teal, useMaterial3: true),
          home: const ContatoreSetState(),
        );
      }
    }
    
    class ContatoreSetState extends StatefulWidget {
      const ContatoreSetState({super.key});
    
      @override
      State<ContatoreSetState> createState() => _ContatoreSetStateState();
    }
    
    class _ContatoreSetStateState extends State<ContatoreSetState> {
      int _contatore = 0;
    
      @override
      Widget build(BuildContext context) {
        debugPrint('build() della schermata eseguito');
        return Scaffold(
          appBar: AppBar(title: const Text('setState')),
          body: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Text('Intestazione statica'),
                Text('$_contatore', style: const TextStyle(fontSize: 48)),
              ],
            ),
          ),
          floatingActionButton: FloatingActionButton(
            onPressed: () => setState(() => _contatore++),
            child: const Icon(Icons.add),
          ),
        );
      }
    }

    Risultato atteso

    L'app mostra un contatore. Nella console di debug compare "build() della schermata eseguito" a ogni tocco del pulsante.

  2. 2

    Il primo ValueNotifier con ValueListenableBuilder

    Un ValueNotifier<T> è un oggetto che contiene un valore (.value) e notifica chi lo ascolta ogni volta che il valore viene sostituito.

    ValueListenableBuilder<T> si occupa di iscriversi e disiscriversi automaticamente: riceve il notifier in valueListenable e ricostruisce solo la funzione builder.

    Nota tre dettagli importanti:

    1. il ValueNotifier è dichiarato come campo final dello State, non dentro build (altrimenti verrebbe ricreato a ogni ricostruzione);
    2. per aggiornare il valore si scrive _contatore.value++ oppure _contatore.value = nuovoValore: niente setState;
    3. il build della schermata ora viene eseguito una sola volta, mentre il solo Text del contatore si aggiorna.
    class ContatoreNotifier extends StatefulWidget {
      const ContatoreNotifier({super.key});
    
      @override
      State<ContatoreNotifier> createState() => _ContatoreNotifierState();
    }
    
    class _ContatoreNotifierState extends State<ContatoreNotifier> {
      final ValueNotifier<int> _contatore = ValueNotifier<int>(0);
    
      @override
      Widget build(BuildContext context) {
        debugPrint('build() della schermata eseguito');
        return Scaffold(
          appBar: AppBar(title: const Text('ValueNotifier')),
          body: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Text('Intestazione statica'),
                ValueListenableBuilder<int>(
                  valueListenable: _contatore,
                  builder: (context, valore, _) {
                    debugPrint('rebuild del solo contatore');
                    return Text('$valore', style: const TextStyle(fontSize: 48));
                  },
                ),
              ],
            ),
          ),
          floatingActionButton: FloatingActionButton(
            onPressed: () => _contatore.value++,
            child: const Icon(Icons.add),
          ),
        );
      }
    }

    Risultato atteso

    Il contatore si incrementa, ma in console compare solo "rebuild del solo contatore": il build della schermata non viene più rieseguito.

  3. 3

    Liberare le risorse con dispose (e il parametro child)

    Un ValueNotifier è un oggetto ascoltabile: se non viene chiuso quando la schermata viene rimossa, i listener restano appesi e si rischia un memory leak. La regola è semplice: ogni notifier creato dallo State va chiuso in dispose().

    In più ValueListenableBuilder offre il parametro child: serve per i widget che non dipendono dal valore. Il child viene costruito una sola volta e passato al builder come terzo argomento, evitando di ricrearlo a ogni notifica. È un'ottimizzazione gratuita, utile quando il sottoalbero contiene widget costosi (immagini, icone grandi, liste).

    class _ContatoreNotifierState extends State<ContatoreNotifier> {
      final ValueNotifier<int> _contatore = ValueNotifier<int>(0);
    
      @override
      void dispose() {
        _contatore.dispose(); // sempre prima di super.dispose()
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Text('ValueNotifier')),
          body: Center(
            child: ValueListenableBuilder<int>(
              valueListenable: _contatore,
              // Costruito una sola volta: non dipende dal valore.
              child: const Icon(Icons.shopping_cart, size: 64),
              builder: (context, valore, child) {
                return Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    child!,
                    Text('Articoli: $valore',
                        style: Theme.of(context).textTheme.headlineMedium),
                  ],
                );
              },
            ),
          ),
          floatingActionButton: FloatingActionButton(
            onPressed: () => _contatore.value++,
            child: const Icon(Icons.add),
          ),
        );
      }
    }

    Risultato atteso

    L'app funziona come prima, l'icona non viene ricreata a ogni aggiornamento e chiudendo la schermata non restano listener attivi.

  4. 4

    Gestire una lista: l'insidia dell'uguaglianza

    Qui si nasconde l'errore più comune dei principianti. ValueNotifier notifica solo se il nuovo valore è diverso dal precedente (confronto con ==). Per gli oggetti come List, == confronta l'identità: se aggiungi un elemento alla stessa lista con add(), l'oggetto resta lo stesso e nessuna notifica viene inviata.

    La soluzione corretta è creare una nuova lista ogni volta:

    _articoli.value = [..._articoli.value, nuovo]; // ✅ notifica
    _articoli.value.add(nuovo);                    // ❌ non notifica
    

    Costruiamo quindi la lista della spesa: un TextField per inserire l'articolo e una ListView che reagisce alle modifiche.

    class ListaSpesa extends StatefulWidget {
      const ListaSpesa({super.key});
    
      @override
      State<ListaSpesa> createState() => _ListaSpesaState();
    }
    
    class _ListaSpesaState extends State<ListaSpesa> {
      final ValueNotifier<List<String>> _articoli = ValueNotifier<List<String>>([]);
      final TextEditingController _campo = TextEditingController();
    
      void _aggiungi() {
        final testo = _campo.text.trim();
        if (testo.isEmpty) return;
        // Nuova lista -> il notifier avvisa gli ascoltatori
        _articoli.value = [..._articoli.value, testo];
        _campo.clear();
      }
    
      void _rimuovi(int indice) {
        final copia = [..._articoli.value]..removeAt(indice);
        _articoli.value = copia;
      }
    
      @override
      void dispose() {
        _articoli.dispose();
        _campo.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Text('Lista della spesa')),
          body: Column(
            children: [
              Padding(
                padding: const EdgeInsets.all(16),
                child: Row(
                  children: [
                    Expanded(
                      child: TextField(
                        controller: _campo,
                        decoration: const InputDecoration(
                          labelText: 'Nuovo articolo',
                          border: OutlineInputBorder(),
                        ),
                        onSubmitted: (_) => _aggiungi(),
                      ),
                    ),
                    const SizedBox(width: 8),
                    IconButton.filled(
                      onPressed: _aggiungi,
                      icon: const Icon(Icons.add),
                    ),
                  ],
                ),
              ),
              Expanded(
                child: ValueListenableBuilder<List<String>>(
                  valueListenable: _articoli,
                  builder: (context, lista, _) {
                    if (lista.isEmpty) {
                      return const Center(child: Text('Nessun articolo'));
                    }
                    return ListView.builder(
                      itemCount: lista.length,
                      itemBuilder: (context, i) => ListTile(
                        leading: CircleAvatar(child: Text('${i + 1}')),
                        title: Text(lista[i]),
                        trailing: IconButton(
                          icon: const Icon(Icons.delete_outline),
                          onPressed: () => _rimuovi(i),
                        ),
                      ),
                    );
                  },
                ),
              ),
            ],
          ),
        );
      }
    }

    Risultato atteso

    Scrivendo un articolo e premendo + l'elemento compare nella lista; il cestino lo rimuove. Solo la ListView viene ricostruita.

  5. 5

    Ascoltare il TextEditingController (è anche lui un ValueNotifier)

    Una scoperta utile: molte classi di Flutter estendono ValueNotifier. TextEditingController, per esempio, è un ValueNotifier<TextEditingValue>, quindi puoi passarlo direttamente a un ValueListenableBuilder.

    Sfruttiamolo per abilitare il pulsante "Aggiungi" solo quando il campo non è vuoto, senza setState e senza listener manuali: il resto della schermata non si ricostruisce mentre l'utente digita.

    Sostituisci il Row del passo precedente con questo blocco.

    Row(
      children: [
        Expanded(
          child: TextField(
            controller: _campo,
            decoration: const InputDecoration(
              labelText: 'Nuovo articolo',
              border: OutlineInputBorder(),
            ),
            onSubmitted: (_) => _aggiungi(),
          ),
        ),
        const SizedBox(width: 8),
        ValueListenableBuilder<TextEditingValue>(
          valueListenable: _campo, // TextEditingController è un ValueNotifier!
          builder: (context, value, _) {
            final abilitato = value.text.trim().isNotEmpty;
            return FilledButton.icon(
              onPressed: abilitato ? _aggiungi : null,
              icon: const Icon(Icons.add),
              label: const Text('Aggiungi'),
            );
          },
        ),
      ],
    )

    Risultato atteso

    Il pulsante "Aggiungi" resta disattivato finché il campo è vuoto e si attiva appena si digita qualcosa.

  6. 6

    Estrarre un controller riutilizzabile e combinare più notifier

    Finché i notifier vivono nello State va bene, ma se vuoi testare la logica o condividerla conviene spostarla in una classe separata, una sorta di mini view-model senza dipendenze esterne.

    Nell'esempio il SpesaController espone due notifier: la lista e un filtro "solo da comprare". Nidificando due ValueListenableBuilder ottieni una UI che reagisce a entrambi.

    Suggerimento: se hai bisogno di combinare molti valori, valuta Listenable.merge([a, b]) con un ListenableBuilder, oppure passa a un package di state management quando la logica cresce. ValueNotifier resta la scelta ideale per lo stato semplice e locale.

    class Articolo {
      const Articolo({required this.nome, this.comprato = false});
      final String nome;
      final bool comprato;
    
      Articolo copyWith({bool? comprato}) =>
          Articolo(nome: nome, comprato: comprato ?? this.comprato);
    }
    
    class SpesaController {
      final ValueNotifier<List<Articolo>> articoli = ValueNotifier([]);
      final ValueNotifier<bool> soloDaComprare = ValueNotifier(false);
    
      void aggiungi(String nome) {
        articoli.value = [...articoli.value, Articolo(nome: nome)];
      }
    
      void togglaComprato(int indice) {
        final copia = [...articoli.value];
        copia[indice] = copia[indice].copyWith(comprato: !copia[indice].comprato);
        articoli.value = copia;
      }
    
      void dispose() {
        articoli.dispose();
        soloDaComprare.dispose();
      }
    }
    
    // Uso nella UI
    ValueListenableBuilder<bool>(
      valueListenable: controller.soloDaComprare,
      builder: (context, filtro, _) {
        return ValueListenableBuilder<List<Articolo>>(
          valueListenable: controller.articoli,
          builder: (context, lista, _) {
            final visibili =
                filtro ? lista.where((a) => !a.comprato).toList() : lista;
            return ListView.builder(
              itemCount: visibili.length,
              itemBuilder: (context, i) => CheckboxListTile(
                value: visibili[i].comprato,
                title: Text(visibili[i].nome),
                onChanged: (_) => controller.togglaComprato(lista.indexOf(visibili[i])),
              ),
            );
          },
        );
      },
    )

    Risultato atteso

    La lista mostra le checkbox, il filtro nasconde gli articoli già comprati e la logica è isolata in una classe testabile senza dipendere da Flutter widget.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!