StatelessWidget, StatefulWidget e setState: capire lo stato in Flutter

Foto di freestocks su Unsplash

GuidePrincipiante35 min Flutter 3.x

StatelessWidget, StatefulWidget e setState: capire lo stato in Flutter

In Flutter tutto è un widget: testi, bottoni, padding e persino l'app stessa. Ma non tutti i widget sono uguali: alcuni descrivono una porzione di interfaccia che non cambia mai (StatelessWidget), altri devono poter cambiare aspetto nel tempo in risposta a un tocco, a un dato che arriva dalla rete o al passare di un timer (StatefulWidget).

Capire questa distinzione — e sapere quando e come chiamare setState() — è il passaggio più importante per chi inizia con Flutter. È anche la fonte dei dubbi più frequenti: perché la mia UI non si aggiorna?, perché il valore torna sempre a zero?, dove dichiaro le variabili?

In questo tutorial costruiremo passo passo una piccola app che parte da un widget statico e arriva a un contatore interattivo con controlli abilitati/disabilitati, imparando lungo la strada:

  • la differenza concreta tra StatelessWidget e StatefulWidget;
  • il ruolo della classe State e del metodo build();
  • come funziona setState() e cosa succede realmente quando lo chiami;
  • come passare dati da un widget padre a un figlio con i parametri del costruttore;
  • gli errori più comuni (setState chiamato dopo dispose, variabili dichiarate nel posto sbagliato, logica pesante dentro setState).

Serve solo un progetto Flutter appena creato con flutter create e qualche minuto di tempo.

  1. 1

    Creare il progetto e ripulire main.dart

    Partiamo da un progetto pulito. Da terminale:

    flutter create stato_demo
    cd stato_demo
    

    Apri lib/main.dart e sostituisci tutto il contenuto con il codice qui sotto: una MaterialApp minimale con una HomePage ancora vuota. Nota che MyApp è un StatelessWidget: descrive la configurazione dell'app, che non cambia mai durante l'esecuzione.

    Il metodo build() di uno StatelessWidget riceve il BuildContext e restituisce l'albero dei widget da disegnare. Viene chiamato da Flutter ogni volta che c'è bisogno di ridisegnare quella porzione di schermo: per questo non deve contenere logica pesante, solo la descrizione della UI.

    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: 'Stato Demo',
          theme: ThemeData(
            colorSchemeSeed: Colors.indigo,
            useMaterial3: true,
          ),
          home: const HomePage(),
        );
      }
    }
    
    class HomePage extends StatelessWidget {
      const HomePage({super.key});
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Text('Stato in Flutter')),
          body: const Center(child: Text('Ciao Flutter!')),
        );
      }
    }

    Risultato atteso

    Lanciando `flutter run` vedi una schermata con una AppBar viola e la scritta "Ciao Flutter!" al centro.

  2. 2

    Costruire un StatelessWidget riutilizzabile con parametri

    Uno StatelessWidget non è "un widget che non fa niente": è un widget il cui aspetto dipende solo dai dati che riceve dall'esterno, cioè dai parametri del costruttore. Se quei dati cambiano, il padre ricostruisce il figlio con nuovi valori.

    Creiamo lib/widgets/badge_valore.dart: un riquadro che mostra un'etichetta e un valore. I suoi campi sono final — è una regola: tutti i campi di uno StatelessWidget devono essere immutabili. Se provi a modificarli dall'interno, il widget non si aggiornerà comunque, perché Flutter non sa che qualcosa è cambiato.

    Nota il costruttore const: permette a Flutter di riutilizzare l'istanza e saltare ricostruzioni inutili, un piccolo ma gratuito guadagno di performance.

    import 'package:flutter/material.dart';
    
    class BadgeValore extends StatelessWidget {
      const BadgeValore({
        super.key,
        required this.etichetta,
        required this.valore,
        this.colore = Colors.indigo,
      });
    
      final String etichetta;
      final int valore;
      final Color colore;
    
      @override
      Widget build(BuildContext context) {
        return Container(
          padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
          decoration: BoxDecoration(
            color: colore.withOpacity(0.1),
            borderRadius: BorderRadius.circular(16),
            border: Border.all(color: colore, width: 2),
          ),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Text(
                etichetta,
                style: TextStyle(color: colore, fontWeight: FontWeight.w600),
              ),
              const SizedBox(height: 8),
              Text(
                '$valore',
                style: Theme.of(context).textTheme.displaySmall?.copyWith(
                      color: colore,
                      fontWeight: FontWeight.bold,
                    ),
              ),
            ],
          ),
        );
      }
    }

    Risultato atteso

    Hai un widget riutilizzabile `BadgeValore(etichetta: 'Totale', valore: 3)` che compila senza errori.

  3. 3

    Trasformare la HomePage in uno StatefulWidget

    Ora serve un widget che ricordi qualcosa tra una ricostruzione e l'altra: il numero di click. Convertiamo HomePage in StatefulWidget.

    Uno StatefulWidget è composto da due classi:

    1. la classe del widget (immutabile, con i parametri in ingresso) che implementa createState();
    2. la classe State, dove vivono le variabili mutabili e il metodo build().

    Questa separazione esiste perché il widget viene distrutto e ricreato a ogni ricostruzione dell'albero, mentre l'oggetto State sopravvive ed è il posto giusto dove conservare i dati.

    Suggerimento: in VS Code posiziona il cursore sul nome della classe, premi Ctrl+. (o Cmd+. su macOS) e scegli Convert to StatefulWidget. Android Studio offre lo stesso refactoring.

    Per ora la variabile _contatore esiste ma non cambia ancora: il bottone stampa solo in console.

    class HomePage extends StatefulWidget {
      const HomePage({super.key});
    
      @override
      State<HomePage> createState() => _HomePageState();
    }
    
    class _HomePageState extends State<HomePage> {
      // Lo stato vive QUI, non dentro build().
      int _contatore = 0;
    
      void _incrementa() {
        _contatore++; // ancora senza setState: la UI non si aggiornerà!
        debugPrint('Contatore ora vale: $_contatore');
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Text('Stato in Flutter')),
          body: Center(
            child: BadgeValore(etichetta: 'Click', valore: _contatore),
          ),
          floatingActionButton: FloatingActionButton(
            onPressed: _incrementa,
            child: const Icon(Icons.add),
          ),
        );
      }
    }

    Risultato atteso

    Premendo il bottone la console mostra "Contatore ora vale: 1, 2, 3..." ma il numero a schermo resta fermo su 0: è la dimostrazione che modificare una variabile non basta.

  4. 4

    Aggiornare l'interfaccia con setState()

    Flutter non "osserva" le tue variabili: devi essere tu a dirgli che lo stato è cambiato. Lo fai chiamando setState(), che:

    1. esegue la funzione che gli passi (dove modifichi le variabili);
    2. marca l'oggetto State come dirty;
    3. schedula una nuova chiamata a build() al prossimo frame.

    Regole pratiche:

    • modifica le variabili dentro la callback di setState, non fuori;
    • tieni la callback sincrona e leggerissima: niente await, niente chiamate di rete;
    • se un'operazione è asincrona, fai prima await, poi chiama setState solo per assegnare il risultato.

    Aggiungiamo anche un pulsante per decrementare e uno per azzerare, così vedi che setState può contenere qualsiasi modifica di stato.

    class _HomePageState extends State<HomePage> {
      int _contatore = 0;
    
      void _incrementa() {
        setState(() {
          _contatore++;
        });
      }
    
      void _decrementa() {
        if (_contatore == 0) return; // niente da fare, evitiamo un rebuild inutile
        setState(() {
          _contatore--;
        });
      }
    
      void _azzera() {
        setState(() => _contatore = 0);
      }
    
      @override
      Widget build(BuildContext context) {
        final raggiunto = _contatore >= 10;
    
        return Scaffold(
          appBar: AppBar(
            title: const Text('Stato in Flutter'),
            actions: [
              IconButton(
                onPressed: _contatore == 0 ? null : _azzera,
                icon: const Icon(Icons.refresh),
                tooltip: 'Azzera',
              ),
            ],
          ),
          body: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                BadgeValore(
                  etichetta: 'Click',
                  valore: _contatore,
                  colore: raggiunto ? Colors.green : Colors.indigo,
                ),
                const SizedBox(height: 16),
                Text(raggiunto ? 'Obiettivo raggiunto!' : 'Continua a premere'),
              ],
            ),
          ),
          floatingActionButton: Row(
            mainAxisAlignment: MainAxisAlignment.end,
            children: [
              FloatingActionButton(
                heroTag: 'meno',
                onPressed: _contatore == 0 ? null : _decrementa,
                child: const Icon(Icons.remove),
              ),
              const SizedBox(width: 12),
              FloatingActionButton(
                heroTag: 'piu',
                onPressed: _incrementa,
                child: const Icon(Icons.add),
              ),
            ],
          ),
        );
      }
    }

    Risultato atteso

    Il numero a schermo cambia a ogni tocco, il badge diventa verde a quota 10 e i pulsanti si disabilitano quando il contatore è a zero.

  5. 5

    Far comunicare padre e figlio: dati giù, eventi su

    Il figlio non deve conoscere lo stato del padre: riceve dati in ingresso e restituisce eventi tramite callback. È il pattern data down, events up, la base di ogni architettura Flutter.

    Creiamo un widget PannelloContatore stateless che riceve il valore e due funzioni. Tutta la logica resta nel State del padre, mentre il figlio si limita a disegnare e a notificare.

    Questo ti permette di riutilizzare PannelloContatore ovunque e, soprattutto, rende chiaro dove risiede la "verità" dei dati: una sola fonte, nel widget con stato più vicino possibile ai widget che lo usano.

    class PannelloContatore extends StatelessWidget {
      const PannelloContatore({
        super.key,
        required this.valore,
        required this.onIncrementa,
        this.onDecrementa,
      });
    
      final int valore;
      final VoidCallback onIncrementa;
      final VoidCallback? onDecrementa; // null = pulsante disabilitato
    
      @override
      Widget build(BuildContext context) {
        return Card(
          margin: const EdgeInsets.all(24),
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                IconButton.filledTonal(
                  onPressed: onDecrementa,
                  icon: const Icon(Icons.remove),
                ),
                Text('$valore', style: Theme.of(context).textTheme.headlineMedium),
                IconButton.filled(
                  onPressed: onIncrementa,
                  icon: const Icon(Icons.add),
                ),
              ],
            ),
          ),
        );
      }
    }
    
    // Nel build del padre (_HomePageState):
    // PannelloContatore(
    //   valore: _contatore,
    //   onIncrementa: _incrementa,
    //   onDecrementa: _contatore == 0 ? null : _decrementa,
    // )

    Risultato atteso

    Il contatore funziona esattamente come prima, ma l'interfaccia è ora incapsulata in un widget stateless riutilizzabile.

  6. 6

    Ciclo di vita: initState, dispose e setState asincrono

    Un State ha un ciclo di vita che devi conoscere per evitare gli errori più comuni:

    • initState() — chiamato una sola volta alla creazione: qui inizializzi controller, timer, listener. Non chiamare setState qui (il build non è ancora avvenuto) e non usare context per cose come MediaQuery senza cautela.
    • build() — può essere chiamato molte volte al secondo: nessuna logica pesante qui.
    • dispose() — chiamato alla rimozione del widget: qui cancelli timer, controller e stream, altrimenti hai un memory leak.

    L'errore classico è: setState() called after dispose(). Succede quando un'operazione asincrona termina dopo che l'utente ha già lasciato la schermata. La difesa è controllare if (!mounted) return; prima di chiamare setState.

    Ecco un esempio completo con un timer automatico.

    import 'dart:async';
    
    class _HomePageState extends State<HomePage> {
      int _contatore = 0;
      Timer? _timer;
      bool _caricamento = false;
    
      @override
      void initState() {
        super.initState();
        // Incrementa da solo ogni 3 secondi.
        _timer = Timer.periodic(const Duration(seconds: 3), (_) {
          setState(() => _contatore++);
        });
      }
    
      Future<void> _caricaDalServer() async {
        setState(() => _caricamento = true);
    
        // Simula una chiamata di rete.
        await Future.delayed(const Duration(seconds: 2));
        final valoreRemoto = 42;
    
        // Fondamentale: l'utente potrebbe essere uscito dalla schermata.
        if (!mounted) return;
    
        setState(() {
          _contatore = valoreRemoto;
          _caricamento = false;
        });
      }
    
      @override
      void dispose() {
        _timer?.cancel(); // sempre! altrimenti il timer continua a girare
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Text('Stato in Flutter')),
          body: Center(
            child: _caricamento
                ? const CircularProgressIndicator()
                : PannelloContatore(
                    valore: _contatore,
                    onIncrementa: () => setState(() => _contatore++),
                    onDecrementa:
                        _contatore == 0 ? null : () => setState(() => _contatore--),
                  ),
          ),
          floatingActionButton: FloatingActionButton.extended(
            onPressed: _caricamento ? null : _caricaDalServer,
            icon: const Icon(Icons.cloud_download),
            label: const Text('Carica'),
          ),
        );
      }
    }

    Risultato atteso

    Il contatore avanza da solo ogni 3 secondi, il pulsante "Carica" mostra uno spinner per 2 secondi e poi imposta 42. Uscendo dalla schermata non compaiono errori in console.

  7. 7

    Errori tipici e regole da ricordare

    Chiudiamo con una checklist mentale da tenere a portata di mano.

    1. La UI non si aggiorna → hai modificato la variabile senza setState, oppure la variabile è dichiarata dentro build() (in quel caso viene ricreata a ogni ricostruzione e riparte dal valore iniziale). Le variabili di stato vanno nei campi della classe State.

    2. setState() called during build → stai chiamando setState direttamente nel corpo di build(). Spostalo in una callback (onPressed, onChanged) o in initState/addPostFrameCallback.

    3. setState() called after dispose() → aggiungi if (!mounted) return; dopo ogni await.

    4. Logica pesante dentro setState → esegui prima il calcolo, poi usa setState solo per assegnare il risultato.

    5. Uso di StatefulWidget quando non serve → se il widget non ha dati che cambiano nel tempo, usa StatelessWidget: è più leggero e più chiaro.

    6. Rebuild troppo ampisetState ricostruisce tutto il build() di quel State. Estrai le parti statiche in widget const separati per limitare il lavoro.

    Quando lo stato deve essere condiviso tra schermate diverse, setState non basta più: è il momento di passare a soluzioni come Provider, Riverpod o BLoC. Ma il concetto resta identico: cambio i dati, notifico Flutter, Flutter ridisegna.

    // ❌ SBAGLIATO: la variabile riparte da 0 a ogni rebuild
    @override
    Widget build(BuildContext context) {
      int contatore = 0; // ricreata ogni volta!
      return TextButton(
        onPressed: () => setState(() => contatore++),
        child: Text('$contatore'),
      );
    }
    
    // ✅ CORRETTO: la variabile è un campo del State
    class _EsempioState extends State<Esempio> {
      int _contatore = 0;
    
      @override
      Widget build(BuildContext context) {
        return TextButton(
          onPressed: () => setState(() => _contatore++),
          child: Text('$_contatore'),
        );
      }
    }
    
    // ✅ Async fatto bene
    Future<void> _carica() async {
      final dati = await servizio.leggi(); // fuori da setState
      if (!mounted) return;
      setState(() => _dati = dati);        // dentro setState solo l'assegnazione
    }

    Risultato atteso

    Sai riconoscere e correggere i quattro errori più frequenti legati allo stato e scegliere consapevolmente tra StatelessWidget e StatefulWidget.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!