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

  1. Leggere .value fuori da un contesto tracciato e aspettarsi un rebuild: se non usi Watch, .watch(context) o SignalsMixin, nessuno aggiorna la UI.
  2. Mutare una lista in-place (items.value.add(x)): i signals confrontano per identità, quindi serve assegnare una nuova lista, oppure usare listSignal/mapSignal che gestiscono le mutazioni.
  3. Modificare signals dentro un computed: genera cicli e comportamenti imprevedibili.
  4. Dimenticare il dispose degli effect globali: usa SignalsMixin o conserva la funzione restituita da effect.
  5. 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.