Perché le Key esistono
Prima o poi capita a tutti: si riordina una lista, si rimuove un elemento e improvvisamente lo stato "salta" sul widget sbagliato. Un checkbox spuntato finisce sulla riga vicina, un TextField mantiene il testo di un altro item, un'animazione parte quando non dovrebbe. Nella quasi totalità dei casi la causa è la stessa: mancano le Key.
Per capirle bisogna ricordare che in Flutter esistono tre alberi paralleli:
- l'albero dei Widget: configurazioni immutabili, ricreate a ogni build, economiche da costruire;
- l'albero degli Element: la struttura persistente che collega widget e render object e che contiene lo State degli
StatefulWidget; - l'albero dei RenderObject: quello che si occupa di layout e painting.
Quando il framework ricostruisce una porzione di UI, non butta via tutto: confronta il nuovo widget con quello vecchio nella stessa posizione e decide se può riusare l'Element esistente. La regola è implementata in Widget.canUpdate:
static bool canUpdate(Widget oldWidget, Widget newWidget) {
return oldWidget.runtimeType == newWidget.runtimeType
&& oldWidget.key == newWidget.key;
}
Stesso tipo e stessa key (anche null == null) ⇒ l'Element viene aggiornato e lo State sopravvive. Altrimenti il vecchio Element viene smontato e ne viene creato uno nuovo, perdendo lo stato.
Il punto chiave è che il confronto avviene per posizione nella lista dei figli. Se non ci sono key e gli elementi si spostano, Flutter è convinto che il primo figlio nuovo corrisponda al primo figlio vecchio: da qui i bug.
Il caso classico: una lista che si rimescola
Creiamo un widget con stato interno, senza key:
class CounterTile extends StatefulWidget {
const CounterTile({required this.label, super.key});
final String label;
@override
State<CounterTile> createState() => _CounterTileState();
}
class _CounterTileState extends State<CounterTile> {
int _count = 0;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(widget.label),
trailing: Text('$_count'),
onTap: () => setState(() => _count++),
);
}
}
E una schermata che permette di invertire l'ordine:
class DemoPage extends StatefulWidget {
const DemoPage({super.key});
@override
State<DemoPage> createState() => _DemoPageState();
}
class _DemoPageState extends State<DemoPage> {
List<String> items = ['Alpha', 'Beta', 'Gamma'];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
actions: [
IconButton(
icon: const Icon(Icons.swap_vert),
onPressed: () => setState(() => items = items.reversed.toList()),
),
],
),
body: Column(
children: [
for (final item in items) CounterTile(label: item),
],
),
);
}
}
Incrementate il contatore di "Alpha", poi invertite l'ordine: il valore resta in cima, attaccato alla posizione e non all'elemento. Aggiungendo una key stabile il problema sparisce:
for (final item in items)
CounterTile(key: ValueKey(item), label: item),
Ora Flutter riconosce l'Element di "Alpha" anche se si è spostato e lo riordina invece di ricrearlo.
Nota: con i
StatelessWidgetsenza figli stateful il problema non si vede, perché non c'è stato da preservare. Le key contano quando c'è stato (State, controller, animazioni, scroll position) o quando servono animazioni corrette in liste dinamiche.
Dove va messa la key
Errore frequentissimo: mettere la key dentro il widget che si sposta invece che sulla sua radice.
// ❌ inutile: il ListTile interno è già figlio di un CounterTile senza key
ListTile(key: ValueKey(item), ...)
// ✅ la key va sul widget più in alto del sottoalbero che cambia posizione
CounterTile(key: ValueKey(item), label: item)
La regola pratica: la key va assegnata al nodo più alto del sottoalbero che può spostarsi, essere rimosso o riordinato tra un frame e l'altro.
I tipi di key e quando usarli
ValueKey
Usa un valore con ==/hashCode significativi: l'id di un record, uno slug, un enum.
ValueKey(todo.id)
È la scelta di default. Attenzione a non usare l'indice della lista come valore: ValueKey(index) è equivalente a non avere key, perché l'indice cambia quando la lista si riordina.
ObjectKey
Confronta per identità dell'oggetto (identical), non per uguaglianza di valore. Utile quando gli oggetti non hanno un id univoco ma le istanze sono stabili nel tempo.
ObjectKey(person) // due persone omonime restano distinte
UniqueKey
Ogni istanza è diversa da qualunque altra. Serve quando si vuole forzare la ricostruzione di un sottoalbero, non per identificare elementi in una lista: creata in build, cambierebbe a ogni frame distruggendo lo stato.
// forza il reset completo del player quando cambia sorgente
VideoWidget(key: UniqueKey(), url: url)
PageStorageKey
Specializzata nel salvare e ripristinare valori (tipicamente la posizione di scroll) tramite PageStorage. Indispensabile con TabBarView, IndexedStack o NestedScrollView, dove le liste vengono smontate e rimontate:
ListView.builder(
key: const PageStorageKey<String>('feed_list'),
itemCount: posts.length,
itemBuilder: (context, i) => PostCard(post: posts[i]),
)
GlobalKey
È una categoria a parte: identifica un widget in tutta l'app, dà accesso al suo State, al suo BuildContext e al suo RenderObject, e permette di spostare un sottoalbero in un punto diverso dell'albero mantenendo lo stato (GlobalKey reparenting).
L'uso più comune resta la validazione di un form:
final _formKey = GlobalKey<FormState>();
Form(
key: _formKey,
child: Column(
children: [
TextFormField(
validator: (v) => (v == null || v.isEmpty) ? 'Campo obbligatorio' : null,
),
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
}
},
child: const Text('Invia'),
),
],
),
)
Un altro uso legittimo è misurare un widget dopo il layout:
final _boxKey = GlobalKey();
void _measure() {
final box = _boxKey.currentContext?.findRenderObject() as RenderBox?;
if (box != null) {
debugPrint('Dimensioni: ${box.size}');
}
}
Costi e insidie delle GlobalKey:
- sono più pesanti delle key locali (registro globale, gestione del reparenting);
- una GlobalKey non può comparire due volte nello stesso albero: lo stesso widget renderizzato in due punti provoca un'eccezione;
- vanno create come campo dello
State(final), mai dentrobuild; - usarle per far comunicare widget lontani è un anti-pattern: per quello esistono
InheritedWidget, provider, BLoC o Riverpod.
Key e animazioni: AnimatedSwitcher, AnimatedList, Hero
AnimatedSwitcher decide se animare proprio in base a canUpdate. Se il widget nuovo ha lo stesso tipo e nessuna key, la transizione non parte:
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Text(
'$counter',
key: ValueKey<int>(counter), // senza key non c'è cross-fade
style: Theme.of(context).textTheme.headlineMedium,
),
)
Lo stesso vale per AnimatedList, ReorderableListView (che richiede key univoche e stabili su ogni figlio) e per le transizioni Hero tra pagine:
ReorderableListView(
onReorder: _onReorder,
children: [
for (final task in tasks)
TaskTile(key: ValueKey(task.id), task: task),
],
)
Un caso subdolo: due branch dello stesso tipo
// ❌ passando da editing a lettura, il TextField mantiene lo stato
isEditing
? TextField(controller: c)
: TextField(controller: c, readOnly: true)
Qui i due widget hanno lo stesso runtimeType e nessuna key: l'Element viene riusato, con effetti collaterali su focus e selezione. Basta differenziarli:
isEditing
? TextField(key: const ValueKey('edit'), controller: c)
: TextField(key: const ValueKey('read'), controller: c, readOnly: true)
Al contrario, se volete che lo stato sopravviva al cambio di ramo, assicuratevi che la key sia identica in entrambi i casi.
Quando NON servono le key
Aggiungere key ovunque "per sicurezza" è controproducente: aumenta il rumore e può addirittura impedire il riuso degli Element. Potete tranquillamente farne a meno quando:
- la lista di figli è statica (numero e ordine fissi);
- i figli sono
StatelessWidgetsenza stato interno né controller; - l'unica cosa che cambia sono i dati mostrati, non la struttura.
Un'eccezione utile: le key nei test e nell'automazione (find.byKey(const Key('login_button'))) sono sempre benvenute, perché rendono i test indipendenti dalle stringhe localizzate.
Checklist pratica
- La lista può essere riordinata, filtrata o accorciata? Metti una
ValueKeycon l'id del modello sulla radice di ogni item. - Gli item hanno stato interno (checkbox,
TextEditingController, animazioni)? Le key sono obbligatorie. - Vuoi forzare un reset completo?
UniqueKeyo una key che cambia con l'identificatore della risorsa. - Devi conservare lo scroll tra tab?
PageStorageKey. - Ti serve accedere allo
Stateo alRenderObject?GlobalKey, ma con parsimonia e mai creata inbuild. - Stai usando l'indice come key? Rimuovilo: non risolve nulla.
Conclusioni
Le Key non sono un dettaglio esotico del framework: sono il meccanismo con cui Flutter decide cosa riusare e cosa ricreare. Capire il legame tra widget, Element e canUpdate trasforma una serie di bug apparentemente casuali — stato che salta, animazioni che non partono, scroll che si resetta — in problemi con una soluzione deterministica di una riga. La regola d'oro resta semplice: key stabili e derivate dai dati sulla radice dei sottoalberi che si muovono, nient'altro.