Gestire le richieste asincrone in Flutter con FutureBuilder e StreamBuilder
GuideIntermedio35 min Flutter 3.x

Gestire le richieste asincrone in Flutter con FutureBuilder e StreamBuilder

Introduzione

In quasi ogni app reale dovrai mostrare dati che arrivano in modo asincrono: una risposta da un server, una lettura da database, un timer o un flusso continuo di eventi. Flutter offre due widget potentissimi per collegare questi dati alla UI in modo dichiarativo: FutureBuilder (per un singolo risultato futuro) e StreamBuilder (per una sequenza di valori nel tempo).

In questo tutorial costruiremo esempi pratici che mostrano come gestire correttamente gli stati di caricamento, errore e dati pronti, evitando gli errori più comuni come la ricreazione del Future ad ogni rebuild.

Al termine saprai scegliere lo strumento giusto in base al tuo caso d'uso e scrivere UI robuste e pulite.

  1. 1

    Capire la differenza tra Future e Stream

    Prima di scrivere widget, è fondamentale capire i due concetti:

    • Un Future rappresenta un valore che sarà disponibile una sola volta in futuro (es. il risultato di una chiamata HTTP).
    • Uno Stream rappresenta una sequenza di valori emessi nel tempo (es. aggiornamenti di posizione, ticker, eventi WebSocket).

    Di conseguenza useremo FutureBuilder per un risultato singolo e StreamBuilder per dati continui.

    // Esempio di Future: un singolo valore dopo 2 secondi
    Future<String> caricaMessaggio() async {
      await Future.delayed(const Duration(seconds: 2));
      return 'Dati caricati con successo!';
    }
    
    // Esempio di Stream: un valore al secondo
    Stream<int> contatore() async* {
      int i = 0;
      while (true) {
        await Future.delayed(const Duration(seconds: 1));
        yield i++;
      }
    }

    Risultato atteso

    Hai definito una funzione che restituisce un Future e una che restituisce uno Stream, pronte da collegare alla UI.

  2. 2

    Usare FutureBuilder per un singolo risultato

    FutureBuilder ricostruisce la UI ogni volta che cambia lo stato del Future. La proprietà snapshot.connectionState ci dice se siamo in attesa, mentre snapshot.hasError e snapshot.hasData ci dicono l'esito.

    Gestiamo sempre tutti e tre i casi: caricamento, errore e dati pronti.

    FutureBuilder<String>(
      future: caricaMessaggio(),
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const Center(child: CircularProgressIndicator());
        }
        if (snapshot.hasError) {
          return Center(child: Text('Errore: ${snapshot.error}'));
        }
        return Center(
          child: Text(
            snapshot.data ?? 'Nessun dato',
            style: const TextStyle(fontSize: 20),
          ),
        );
      },
    )

    Risultato atteso

    Vedrai uno spinner per 2 secondi, poi il testo 'Dati caricati con successo!'.

  3. 3

    Evitare l'errore più comune: il Future ricreato ad ogni rebuild

    Un errore frequentissimo è passare future: caricaMessaggio() direttamente nel build. Ogni volta che il widget viene ricostruito (es. per un setState o una rotazione), il Future viene rieseguito, mostrando di nuovo lo spinner.

    La soluzione corretta è creare il Future una sola volta in un StatefulWidget, ad esempio in initState.

    class MessaggioPage extends StatefulWidget {
      const MessaggioPage({super.key});
      @override
      State<MessaggioPage> createState() => _MessaggioPageState();
    }
    
    class _MessaggioPageState extends State<MessaggioPage> {
      late final Future<String> _futureMessaggio;
    
      @override
      void initState() {
        super.initState();
        _futureMessaggio = caricaMessaggio(); // creato una sola volta
      }
    
      @override
      Widget build(BuildContext context) {
        return FutureBuilder<String>(
          future: _futureMessaggio,
          builder: (context, snapshot) {
            if (snapshot.connectionState == ConnectionState.waiting) {
              return const Center(child: CircularProgressIndicator());
            }
            if (snapshot.hasError) {
              return Center(child: Text('Errore: ${snapshot.error}'));
            }
            return Center(child: Text(snapshot.data ?? ''));
          },
        );
      }
    }

    Risultato atteso

    Anche dopo rebuild o rotazioni, il Future non viene rieseguito e lo spinner non riappare inutilmente.

  4. 4

    Usare StreamBuilder per dati continui

    Per un flusso di valori usiamo StreamBuilder. La struttura è simile a FutureBuilder, ma snapshot.data viene aggiornato ad ogni nuovo evento emesso dallo Stream.

    Anche qui conviene creare lo Stream una sola volta (in initState o come campo) per evitare che venga riavviato ad ogni rebuild.

    class ContatorePage extends StatefulWidget {
      const ContatorePage({super.key});
      @override
      State<ContatorePage> createState() => _ContatorePageState();
    }
    
    class _ContatorePageState extends State<ContatorePage> {
      late final Stream<int> _stream;
    
      @override
      void initState() {
        super.initState();
        _stream = contatore();
      }
    
      @override
      Widget build(BuildContext context) {
        return StreamBuilder<int>(
          stream: _stream,
          initialData: 0,
          builder: (context, snapshot) {
            if (snapshot.hasError) {
              return Center(child: Text('Errore: ${snapshot.error}'));
            }
            return Center(
              child: Text(
                'Valore: ${snapshot.data}',
                style: const TextStyle(fontSize: 32),
              ),
            );
          },
        );
      }
    }

    Risultato atteso

    Il numero a schermo si incrementa automaticamente ogni secondo.

  5. 5

    Gestire le risorse: chiudere gli StreamController

    Quando crei manualmente uno StreamController, devi chiuderlo quando il widget viene rimosso, per evitare memory leak. Questo si fa nel metodo dispose.

    Vediamo un esempio in cui controlliamo noi lo Stream con uno StreamController.

    class TimerManualePage extends StatefulWidget {
      const TimerManualePage({super.key});
      @override
      State<TimerManualePage> createState() => _TimerManualePageState();
    }
    
    class _TimerManualePageState extends State<TimerManualePage> {
      final _controller = StreamController<int>();
      Timer? _timer;
      int _secondi = 0;
    
      @override
      void initState() {
        super.initState();
        _timer = Timer.periodic(const Duration(seconds: 1), (_) {
          _secondi++;
          _controller.add(_secondi);
        });
      }
    
      @override
      void dispose() {
        _timer?.cancel();
        _controller.close(); // fondamentale per evitare leak
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return StreamBuilder<int>(
          stream: _controller.stream,
          initialData: 0,
          builder: (context, snapshot) =>
              Center(child: Text('Secondi: ${snapshot.data}')),
        );
      }
    }

    Risultato atteso

    Il timer funziona correttamente e le risorse vengono liberate quando si esce dalla pagina.

  6. 6

    Aggiungere un pulsante di ricarica con FutureBuilder

    Spesso vuoi permettere all'utente di riprovare dopo un errore o aggiornare i dati. Possiamo farlo riassegnando il Future dentro un setState. Poiché lo riassegniamo solo quando l'utente preme il pulsante, evitiamo ricariche indesiderate.

    ElevatedButton(
      onPressed: () {
        setState(() {
          _futureMessaggio = caricaMessaggio(); // forza il ricaricamento
        });
      },
      child: const Text('Ricarica'),
    )
    
    // Inseriscilo accanto al FutureBuilder dentro una Column nel build()

    Risultato atteso

    Premendo 'Ricarica' lo spinner riappare e i dati vengono caricati nuovamente, in modo controllato.

  7. 7

    Quando usare cosa: riepilogo e best practice

    Per chiudere, ecco una sintesi pratica:

    • FutureBuilder: usa per operazioni che restituiscono un solo valore (chiamata API, lettura DB singola). Crea il Future fuori dal build.
    • StreamBuilder: usa per flussi continui (real-time, timer, posizione GPS, WebSocket).
    • Gestisci sempre i tre stati: waiting, hasError, dati pronti.
    • Per gli Stream creati manualmente, chiudi sempre lo StreamController in dispose.
    • Per logiche di stato più complesse, considera soluzioni come Provider, Riverpod o Bloc, che usano questi widget internamente.

    Con queste basi puoi costruire UI reattive e robuste in qualsiasi app Flutter.

    Risultato atteso

    Hai una visione chiara di quale widget usare e delle best practice per gestire dati asincroni in Flutter.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!