Cosa sono i signals
I signals sono un modello di reattività nato nel mondo JavaScript (SolidJS, Angular, Preact, Vue) e arrivato in Dart grazie al pacchetto signals. L'idea è semplice: un signal è un contenitore di valore che tiene traccia automaticamente di chi lo legge. Quando il valore cambia, solo chi lo ha letto viene notificato.
La differenza rispetto a ChangeNotifier o ai provider classici è il livello di granularità. Con un ChangeNotifier sei tu a decidere quando chiamare notifyListeners() e quali widget ascoltare; con i signals il grafo delle dipendenze viene costruito a runtime, durante la lettura, e gli aggiornamenti si propagano solo lungo i rami effettivamente interessati. È la cosiddetta fine-grained reactivity.
Il risultato pratico in Flutter è notevole: niente select manuali, niente Consumer annidati per limitare i rebuild, nessuna dipendenza dal BuildContext per leggere lo stato.
Installazione
dependencies:
signals: ^6.0.0
Il pacchetto signals include sia il core Dart puro (signals_core) sia le estensioni Flutter (signals_flutter). Se stai scrivendo un package di dominio senza dipendenze da Flutter, puoi usare direttamente signals_core.
Le tre primitive: signal, computed, effect
signal
Un signal è uno stato mutabile osservabile:
import 'package:signals/signals.dart';
final counter = signal(0);
void increment() => counter.value++;
La lettura avviene con .value, la scrittura assegnando a .value. Esiste anche counter.set(5) e, per i casi in cui vuoi forzare la notifica anche con valore identico, counter.set(5, force: true).
computed
Un computed è un valore derivato e memoizzato: si ricalcola solo quando una delle sue dipendenze cambia, ed è lazy (non viene calcolato finché qualcuno non lo legge).
final items = signal(<CartItem>[]);
final coupon = signal<double>(0);
final subtotal = computed(
() => items.value.fold<double>(0, (sum, i) => sum + i.price * i.qty),
);
final total = computed(() => subtotal.value * (1 - coupon.value));
Nota che total dipende da subtotal, che a sua volta dipende da items: il grafo si costruisce da solo. Se modifichi solo coupon, subtotal non viene ricalcolato.
effect
Un effect esegue codice imperativo ogni volta che una delle dipendenze lette al suo interno cambia. Restituisce una funzione di dispose.
final dispose = effect(() {
debugPrint('Totale aggiornato: ${total.value}');
});
// quando non serve più
dispose();
Il callback può restituire a sua volta una funzione di cleanup, eseguita prima di ogni ri-esecuzione e al dispose:
effect(() {
final sub = repository.watchUser(userId.value).listen(_onUser);
return sub.cancel; // cleanup
});
Regola d'oro: i computed sono puri, gli effect hanno side effect. Non modificare signals dentro un computed.
Collegare i signals ai widget
Ci sono due modi principali, entrambi forniti da signals_flutter.
1. Il widget Watch
Watch ricostruisce solo il suo subtree quando cambia un signal letto nel builder:
class CartTotal extends StatelessWidget {
const CartTotal({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: [
const Text('Totale carrello'), // non viene mai ricostruito
Watch((context) => Text('€ ${total.value.toStringAsFixed(2)}')),
],
);
}
}
Questo è l'approccio consigliato: isolare le parti dinamiche riduce al minimo il lavoro del framework, esattamente come faresti con ValueListenableBuilder ma senza doverne gestire l'istanza.
2. L'extension .watch(context)
Se preferisci una sintassi più compatta, puoi leggere il signal direttamente nel build di un widget:
class CounterText extends StatelessWidget {
const CounterText({super.key});
@override
Widget build(BuildContext context) {
final value = counter.watch(context);
return Text('$value');
}
}
In questo caso viene ricostruito l'intero widget, quindi mantienilo piccolo. Esiste anche .listen(context, callback) per reagire ai cambiamenti senza ricostruire (utile per snackbar, navigazione, dialog).
Signals locali e ciclo di vita
I signals globali vanno bene per stato applicativo condiviso, ma spesso serve stato locale a una schermata. Il pacchetto offre SignalsMixin da usare su uno State: i signals creati con i metodi create* vengono automaticamente osservati e distrutti con il widget.
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> with SignalsMixin {
late final query = createSignal('');
late final isValid = createComputed(() => query.value.trim().length >= 3);
@override
void initState() {
super.initState();
createEffect(() {
if (isValid.value) {
debugPrint('Ricerca: ${query.value}');
}
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(onChanged: (v) => query.value = v),
ElevatedButton(
onPressed: isValid.value ? _search : null,
child: const Text('Cerca'),
),
],
);
}
void _search() {/* ... */}
}
Con SignalsMixin la lettura di isValid.value dentro build è già tracciata: non serve Watch. Per i signals globali che vuoi far morire quando nessuno li ascolta più puoi usare signal(0, autoDispose: true).
Gestire l'asincrono
Il punto di forza dei signals emerge con i dati remoti. futureSignal e streamSignal incapsulano uno stato asincrono in un AsyncSignal che espone AsyncState (loading / data / error):
final userId = signal('u-42');
final userSignal = computedAsync(() async {
final id = userId.value; // dipendenza tracciata
return api.fetchUser(id);
});
Quando userId cambia, la richiesta viene rifatta automaticamente e il signal passa in stato di loading. Nella UI:
Watch((context) {
return userSignal.value.map(
data: (user) => UserCard(user: user),
error: (err, _) => ErrorView(message: '$err'),
loading: () => const CircularProgressIndicator(),
);
});
Per i flussi continui (WebSocket, Firestore, sensori) c'è streamSignal, mentre toSignal() converte un Future/Stream esistente. Il vantaggio rispetto a FutureBuilder è che lo stato vive fuori dal widget tree: sopravvive ai rebuild e può essere condiviso tra più schermate.
batch e untracked
Se modifichi più signals in sequenza, ogni assegnazione innesca una propagazione. batch raggruppa le modifiche in una sola notifica:
batch(() {
firstName.value = 'Mario';
lastName.value = 'Rossi';
age.value = 42;
});
untracked invece legge un valore senza creare una dipendenza, utile dentro un effect quando ti serve il dato ma non vuoi ri-eseguire al suo cambiamento:
effect(() {
final q = query.value; // dipendenza
final page = untracked(() => currentPage.value); // non dipendenza
analytics.log('search', {'q': q, 'page': page});
});
Un pattern architetturale pulito
I signals si sposano bene con un'architettura a livelli: il repository resta Dart puro, il view model espone signals, la UI osserva.
class TodoViewModel {
TodoViewModel(this._repo);
final TodoRepository _repo;
final filter = signal(TodoFilter.all);
late final todos = computedAsync(() => _repo.fetch());
late final visible = computed(() {
final list = todos.value.value ?? const <Todo>[];
return switch (filter.value) {
TodoFilter.all => list,
TodoFilter.done => list.where((t) => t.done).toList(),
TodoFilter.open => list.where((t) => !t.done).toList(),
};
});
Future<void> toggle(Todo todo) async {
await _repo.toggle(todo.id);
todos.refresh();
}
}
Il view model non conosce il BuildContext ed è testabile con semplici unit test: crei l'istanza, cambi un signal, leggi il computed.
test('filtra i todo completati', () {
final vm = TodoViewModel(FakeRepo());
vm.filter.value = TodoFilter.done;
expect(vm.visible.value.every((t) => t.done), isTrue);
});
Quando conviene (e quando no)
Conviene se:
- hai molti valori derivati l'uno dall'altro e vuoi evitare ricalcoli manuali;
- vuoi rebuild chirurgici in liste o dashboard con molti elementi indipendenti;
- vuoi logica di stato completamente disaccoppiata da Flutter.
Pensaci due volte se:
- il team è già produttivo con BLoC o Riverpod: il guadagno non giustifica una migrazione totale;
- ti serve un ecosistema molto maturo di tooling e documentazione in italiano: i signals sono relativamente recenti nel mondo Dart;
- ami il flusso di eventi esplicito e tracciabile di BLoC: i signals sono più "magici" e il grafo di dipendenze è implicito.
Nulla vieta un'adozione incrementale: i signals convivono senza problemi con Riverpod o BLoC, e puoi iniziare da una singola schermata complessa.
Errori comuni da evitare
- Leggere
.valuefuori da un contesto tracciato e aspettarsi un rebuild: se non usiWatch,.watch(context)oSignalsMixin, nessuno aggiorna la UI. - Mutare una lista in-place (
items.value.add(x)): i signals confrontano per identità, quindi serve assegnare una nuova lista, oppure usarelistSignal/mapSignalche gestiscono le mutazioni. - Modificare signals dentro un
computed: genera cicli e comportamenti imprevedibili. - Dimenticare il dispose degli effect globali: usa
SignalsMixino conserva la funzione restituita daeffect. - Avvolgere mezzo albero in un
Watch: perdi il vantaggio principale, cioè i rebuild mirati.
Conclusione
I signals offrono un modello mentale compatto — stato, derivazione, effetto — con prestazioni ottime grazie al tracking automatico delle dipendenze. In Flutter si traducono in meno boilerplate rispetto ai ChangeNotifier, rebuild più precisi e una logica di dominio che resta Dart puro. Se stai avviando un progetto nuovo o devi rifattorizzare una schermata con molti stati interdipendenti, vale la pena provarli: il salto concettuale è minimo, il guadagno in leggibilità spesso notevole.