Gestire lo stato locale in Flutter con signals e reattività granulare

Foto di Anna Sullivan su Unsplash

GuideIntermedio35 min Flutter 3.x

Gestire lo stato locale in Flutter con signals e reattività granulare

Gestione dello stato con signals

Il pacchetto signals porta in Flutter un modello di reattività fine-grained (granulare) ispirato a SolidJS e Preact. A differenza di soluzioni come Provider o Riverpod, con i signals ogni valore reattivo sa esattamente quali widget dipendono da esso: quando cambia, solo quei widget vengono ricostruiti, senza notifyListeners manuali né boilerplate.

In questo tutorial costruiremo un piccolo carrello della spesa reattivo, imparando a usare signal, computed, effect e il widget Watch per aggiornare la UI in modo mirato ed efficiente.

  1. 1

    Installare il pacchetto signals

    Aggiungiamo il pacchetto signals al progetto. Puoi usare il comando flutter pub add oppure inserire la dipendenza manualmente nel file pubspec.yaml.

    Il pacchetto è composto da un core Dart e da un'integrazione Flutter (widget come Watch e SignalProvider), entrambi esposti dall'import package:signals/signals_flutter.dart.

    flutter pub add signals

    Risultato atteso

    Nel pubspec.yaml compare la dipendenza signals e `flutter pub get` termina senza errori.

  2. 2

    Creare il primo signal

    Un signal è un contenitore reattivo per un valore. Si legge con .value e si aggiorna assegnando un nuovo valore a .value.

    Creiamo un semplice contatore. Il concetto chiave è che chiunque legga counter.value all'interno di un contesto reattivo verrà automaticamente notificato quando il valore cambia.

    import 'package:signals/signals_flutter.dart';
    
    // Un signal che contiene un intero
    final counter = signal(0);
    
    void incrementa() {
      counter.value++; // aggiorna il valore e notifica i dipendenti
    }
    
    int leggi() {
      return counter.value; // legge il valore corrente
    }

    Risultato atteso

    Hai definito un signal `counter` che potrà essere osservato e modificato.

  3. 3

    Reagire ai cambiamenti nella UI con Watch

    Il widget Watch ricostruisce solo il suo contenuto quando cambiano i signals letti al suo interno. Questo evita di ricostruire l'intero widget tree.

    Avvolgi con Watch esclusivamente la porzione di UI che dipende dal signal: più il Watch è piccolo e mirato, più le ricostruzioni saranno efficienti.

    import 'package:flutter/material.dart';
    import 'package:signals/signals_flutter.dart';
    
    final counter = signal(0);
    
    class ContatoreView extends StatelessWidget {
      const ContatoreView({super.key});
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Text('Signals demo')),
          body: Center(
            // Solo questo testo si ricostruisce quando counter cambia
            child: Watch((context) => Text(
              'Valore: ${counter.value}',
              style: Theme.of(context).textTheme.headlineMedium,
            )),
          ),
          floatingActionButton: FloatingActionButton(
            onPressed: () => counter.value++,
            child: const Icon(Icons.add),
          ),
        );
      }
    }

    Risultato atteso

    Premendo il FAB il testo si aggiorna, ma il resto dello Scaffold non viene ricostruito.

  4. 4

    Valori derivati con computed

    Un computed è un signal di sola lettura calcolato a partire da altri signals. Si aggiorna automaticamente quando cambiano le sue dipendenze e memorizza il risultato (memoization) finché non serve ricalcolarlo.

    Modelliamo un carrello: una lista di prodotti come signal, e il totale come computed che dipende dalla lista.

    import 'package:signals/signals_flutter.dart';
    
    class Prodotto {
      final String nome;
      final double prezzo;
      final int quantita;
      const Prodotto(this.nome, this.prezzo, this.quantita);
    }
    
    // Stato principale: lista di prodotti nel carrello
    final carrello = signal(<Prodotto>[]);
    
    // Valore derivato: totale del carrello
    final totale = computed(() => carrello.value.fold<double>(
          0,
          (somma, p) => somma + p.prezzo * p.quantita,
        ));
    
    // Valore derivato: numero totale di articoli
    final numeroArticoli = computed(
      () => carrello.value.fold<int>(0, (s, p) => s + p.quantita),
    );
    
    void aggiungiProdotto(Prodotto p) {
      // Creiamo una nuova lista per far scattare la notifica
      carrello.value = [...carrello.value, p];
    }

    Risultato atteso

    Aggiungendo prodotti al carrello, `totale` e `numeroArticoli` si ricalcolano automaticamente.

  5. 5

    Eseguire side-effect con effect

    Un effect esegue una funzione ogni volta che cambia uno dei signals letti al suo interno. È utile per side-effect: logging, salvataggio su disco, chiamate di rete.

    effect restituisce una funzione di dispose da chiamare quando non serve più (ad esempio nel dispose di uno State) per evitare memory leak.

    import 'package:flutter/foundation.dart';
    import 'package:signals/signals_flutter.dart';
    
    late final void Function() disposeLogger;
    
    void avviaLogger() {
      disposeLogger = effect(() {
        // Viene rieseguito ad ogni cambiamento di totale o numeroArticoli
        debugPrint('Carrello: ${numeroArticoli.value} articoli, '
            'totale €${totale.value.toStringAsFixed(2)}');
      });
    }
    
    // Quando non serve più:
    // disposeLogger();

    Risultato atteso

    Ogni modifica al carrello stampa in console il riepilogo aggiornato del totale.

  6. 6

    Comporre lo stato in una schermata completa

    Mettiamo insieme i concetti in una schermata carrello. Nota come usiamo Watch separati: uno per la lista e uno per il riepilogo del totale, così le due parti si ricostruiscono in modo indipendente.

    Questa separazione è il grande vantaggio della reattività granulare: aggiungere un prodotto ricostruisce solo la lista e il footer, non tutta la schermata.

    class CarrelloScreen extends StatelessWidget {
      const CarrelloScreen({super.key});
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Text('Carrello')),
          body: Column(
            children: [
              Expanded(
                child: Watch((context) {
                  final prodotti = carrello.value;
                  if (prodotti.isEmpty) {
                    return const Center(child: Text('Carrello vuoto'));
                  }
                  return ListView.builder(
                    itemCount: prodotti.length,
                    itemBuilder: (context, i) {
                      final p = prodotti[i];
                      return ListTile(
                        title: Text(p.nome),
                        subtitle: Text('x${p.quantita}'),
                        trailing: Text('€${(p.prezzo * p.quantita)
                            .toStringAsFixed(2)}'),
                      );
                    },
                  );
                }),
              ),
              // Footer che dipende solo dai computed
              Watch((context) => Container(
                padding: const EdgeInsets.all(16),
                color: Theme.of(context).colorScheme.surfaceVariant,
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    Text('${numeroArticoli.value} articoli'),
                    Text('Totale: €${totale.value.toStringAsFixed(2)}',
                        style: const TextStyle(fontWeight: FontWeight.bold)),
                  ],
                ),
              )),
            ],
          ),
          floatingActionButton: FloatingActionButton.extended(
            onPressed: () => aggiungiProdotto(
              const Prodotto('Caffè', 4.50, 1),
            ),
            label: const Text('Aggiungi'),
            icon: const Icon(Icons.add_shopping_cart),
          ),
        );
      }
    }

    Risultato atteso

    La schermata mostra la lista dei prodotti e un footer con totale e conteggio che si aggiornano automaticamente ad ogni aggiunta.

  7. 7

    Buone pratiche e gestione del ciclo di vita

    Alcune regole per usare i signals in modo efficace:

    • Watch mirati: avvolgi con Watch solo la parte di UI che dipende dal signal, non interi widget.
    • Immutabilità delle collezioni: per liste e mappe assegna sempre una nuova istanza ([...lista]) così il signal rileva il cambiamento.
    • Dispose degli effect: se crei effect o signal legati a uno State, ricordati di rilasciarli.
    • Signal locali con hook: in uno StatefulWidget puoi usare createSignal / bindSignal per legare il ciclo di vita del signal al widget.

    Esempio di signal locale con dispose automatico dentro uno State:

    class LocalCounter extends StatefulWidget {
      const LocalCounter({super.key});
      @override
      State<LocalCounter> createState() => _LocalCounterState();
    }
    
    class _LocalCounterState extends State<LocalCounter> {
      // Signal legato al ciclo di vita del widget
      late final count = createSignal(this, 0);
    
      @override
      Widget build(BuildContext context) {
        return TextButton(
          onPressed: () => count.value++,
          child: Watch((context) => Text('Cliccato ${count.value} volte')),
        );
      }
    }

    Risultato atteso

    Il signal locale viene creato e disposto insieme al widget, evitando memory leak, e il bottone aggiorna solo il proprio testo.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!