Salta al contenuto principale

Eseguire side effects

Fino ad ora, abbiamo visto solo come ottenere dati (cioè eseguire una richiesta HTTP GET).
Ma cosa succede con i side-effects, come una richiesta POST?

Le applicazioni spesso implementano API CRUD (Create, Read, Update, Delete).
Quando lo fanno, è comune che una richiesta di aggiornamento (tipicamente una POST) debba aggiornare anche la cache locale in modo che l'interfaccia utente rifletta il nuovo stato.

Il problema è: come aggiorniamo lo stato di un provider da dentro un consumer?
Di natura, i provider non espongono un modo per modificare il loro stato. Questo è fatto apposta, per garantire che lo stato venga modificato in modo controllato e promuovere la separazione delle responsabilità.
Invece, i provider devono esplicitamente esporre un modo per modificare il loro stato.

Per fare ciò, useremo un nuovo concetto: i Notifiers.
Per illustrare questo nuovo concetto, utilizziamo un esempio più avanzato: una lista di cose da fare (to-do list).

Definire un Notifier

Cominciamo con quello che sappiamo fino a questo punto: una semplice richiesta GET. Come visto precedentemente in Effettua la tua prima richiesta di provider/rete, potremmo ottenere una to-do list scrivendo:


Future<List<Todo>> todoList(TodoListRef ref) async {
// Simula una richiesta di rete. Normalmente il risultato dovrebbe venire da una API reale
return [
Todo(description: 'Learn Flutter', completed: true),
Todo(description: 'Learn Riverpod'),
];
}

Ora che abbiamo ottenuto una to-do list, vediamo come possiamo aggiungere nuovi elementi. Per fare questo, dovremo modificare i nostri provider in modo che espongano un'API pubblica per modificare il loro stato. Questo si fa convertendo il nostro provider in quello che chiamiamo un "notifier".

I notifiers sono il "widget stateful" dei provider. Richiedono una piccola modifica alla sintassi per la definizione di un provider. La nuova sintassi è la seguente:

@riverpod
class MyNotifier extends _$MyNotifier {
  @override
  Result build() {
    <your logic here>
  }
  <your methods here>
}
L'annotazione

Tutti i provider devono essere annotati con @riverpod o @Riverpod(). Questa annotazione può essere posta su funzioni globali o classi.
Attraverso questa annotazione, è possibile configurare il provider.

Per esempio, possiamo disabilitare "auto-dispose" (che vedremo più avanti) scrivendo @Riverpod(keepAlive: true).

Il Notifier

Quando un'annotazione @riverpod è posta su una classe, quella classe viene chiamata "Notifier".
La classe deve estendere _$NotifierName, dove NotifierName è il nome della classe.

I Notifiers sono responsabili di esporre modalità per modificare lo stato del provider.
I metodi pubblici di questa classe sono accessibili ai consumer usando ref.read(yourProvider.notifier).tuoMetodo().

note

I Notifiers non dovrebbero avere proprietà pubbliche oltre alla proprietà built-in state, poiché l'interfaccia utente non avrebbe modo di sapere che lo stato è cambiato.

Il metodo build

Tutti i notifiers devono sovrascrivere il metodo build.
Questo metodo è equivalente al punto in cui normalmente inseriresti la tua logica in un provider non-notifier.

Questo metodo non dovrebbe essere chiamato direttamente.

Per ulteriori informazioni, potresti voler consultare Effettua la tua prima richiesta di provider/rete per confrontare questa nuova sintassi con quella vista in precedenza.

info

Un Notifier senza metodi al di fuori di build è identico all'utilizzo della sintassi vista in precedenza. La sintassi mostrata in Effettua la tua prima richiesta di provider/rete può essere considerata come una scorciatoia per i notifiers senza possibilità di essere modificati dall'interfaccia utente.

Ora che abbiamo visto la sintassi, vediamo come convertire il nostro provider precedentemente definito in un notifier:


class TodoList extends _$TodoList {

Future<List<Todo>> build() async {
// La logica che precedentemente avevamo nel nostro FutureProvider è ora nel metodo 'build'.
return [
Todo(description: 'Learn Flutter', completed: true),
Todo(description: 'Learn Riverpod'),
];
}
}

Notiamo che il modo di leggere il provider all'interno dei widget non è cambiato.
Puoi ancora usare ref.watch(todoListProvider) come nella sintassi precedente.

caution

Non inserire la logica nel costruttore del tuo notifier.
I notifiers non dovrebbero avere un costruttore, poiché ref e altre proprietà non sono ancora disponibili in quel momento. Invece, inserisci la tua logica nel metodo build.

class MyNotifier extends ... {
MyNotifier() {
// ❌ Non fare questo
// Causerà un'eccezione
state = AsyncValue.data(42);
}


Result build() {
// ✅ Fare questo invece
state = AsyncValue.data(42);
}
}

Esporre un metodo per effettuare una richiesta POST

Ora che abbiamo un Notifier, possiamo iniziare ad aggiungere metodi per abilitare i side-effects. Un side-effect del genere potrebbe essere quello di far eseguire al client una richiesta POST per aggiungere un nuovo elemento todo. Possiamo farlo aggiungendo un metodo addTodo al nostro notifier:


class TodoList extends _$TodoList {

Future<List<Todo>> build() async => [/* ... */];

Future<void> addTodo(Todo todo) async {
await http.post(
Uri.https('your_api.com', '/todos'),
// Serializziamo il nostro oggetto Todo e lo salviamo tramite POST sul server.
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);
}
}
info

Nota come stiamo usando ref.read invece di ref.watch per invocare il nostro metodo. Anche se ref.watch potrebbe funzionare tecnicamente, si consiglia di utilizzare ref.read quando si esegue la logica negli event handlers come "onPressed".

Abbiamo ora un pulsante che effettua una richiesta POST quando premuto. Tuttavia, al momento, la nostra interfaccia utente non si aggiorna per riflettere la nuova to-do list. Vogliamo che la nostra cache locale corrisponda allo stato del server.

Esistono diversi modi per farlo, ognuno con i suoi vantaggi e svantaggi.

Aggiornare la cache locale per riflettere la risposta dell'API

Una pratica comune lato server è fare sì che la richiesta POST restituisca il nuovo stato della risorsa. In particolare, la nostra API restituirebbe la nuova lista to-do dopo l'aggiunta di un nuovo to-do. Un modo per farlo è scrivere state = AsyncData(response):

  Future<void> addTodo(Todo todo) async {
// La richiesta POST restituirà una List<Todo> corrispondente al nuovo stato dell'applicazione
final response = await http.post(
Uri.https('your_api.com', '/todos'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);

// Decodifichiamo la risposta API e la convertiamo in una List<Todo>
List<Todo> newTodos = (jsonDecode(response.body) as List)
.cast<Map<String, Object?>>()
.map(Todo.fromJson)
.toList();

// Aggiorniamo la cache locale per riflettere il nuovo stato.
// Questo notificherà tutti i suoi ascoltatori.
state = AsyncData(newTodos);
}
pro
  • L'interfaccia utente avrà lo stato più aggiornato possibile. Se un altro utente ha aggiunto un to-do, la vedremo anche noi.
  • Il server è la fonte di verità. Con questo approccio, il client non ha bisogno di sapere dove il nuovo to-do deve essere inserito nella list to-do.
  • È necessaria solo una singola richiesta di rete.
contro
  • Questo approccio funzionerà solo se il server è implementato in un modo specifico. Se il server non restituisce il nuovo stato, questo approccio non funzionerà.
  • Potrebbe ancora non essere fattibile se la richiesta GET associata è più complessa, ad esempio se ha filtri/ordinamenti.

Usare ref.invalidateSelf() per ricaricare il provider.

Un'opzione è far eseguire nuovamente la richiesta GET al nostro provider.
Questo può essere fatto chiamando ref.invalidateSelf() dopo la richiesta POST:

  Future<void> addTodo(Todo todo) async {
// Non ci importa della risposta dell'API
await http.post(
Uri.https('your_api.com', '/todos'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);

// Una volta che la richiesta è terminata, possiamo marcare la cache locale come sporca.
// Facendo ciò, il metodo "build" sul nostro notifier verrà chiamato asincronamente di nuovo,
// notificando i suoi listener.
ref.invalidateSelf();

// (Opzionale) Possiamo quindi aspettare che il nuovo stato venga computato.
// Questo assicura che "addTodo" non venga completato finchè il nuovo stato non è disponibile.
await future;
}
pro
  • L'interfaccia utente avrà lo stato più aggiornato possibile. Se un altro utente ha aggiunto un nuovo to-do, lo vedremo anche noi.
  • Il server è la fonte di verità. Con questo approccio, il client non ha bisogno di sapere dove inserire il nuovo to-do nella lista dei to-do.
  • Questo approccio dovrebbe funzionare indipendentemente dall'implementazione del server. Può essere particolarmente utile se la tua richiesta GET è più complessa, ad esempio se ha filtri/ordinamenti.
contro
  • Questo approccio eseguirà una richiesta GET aggiuntiva, il che potrebbe essere inefficiente.

Aggiornare la cache locale manualmente

Un'altra opzione è quella di aggiornare manualmente la cache locale. Ciò implicherebbe cercare di imitare il comportamento del backend. Ad esempio, dovremmo sapere se il backend inserisce nuovi elementi all'inizio o alla fine.

  Future<void> addTodo(Todo todo) async {
// Non ci importa della risposta dell'API
await http.post(
Uri.https('your_api.com', '/todos'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);

// Possiamo quindi aggiornare manualmente la cache locale. Per fare ciò, avremo bisogno
// di ottenere lo stato precedente.
// Attenzione: lo stato precedente potrebbe essere anche in stato di loading o di errore.
// Un modo elegante di gestirlo sarebbe leggere `this.future` invece
// di `this.state`, il che consentirebbe di attendere lo stato di loading e
// generare un errore se lo stato è in uno stato di errore.
final previousState = await future;

// Possiamo quindi aggiornare lo stato, creando un nuovo oggetto di stato.
// Ciò notificherà i suoi listener.
state = AsyncData([...previousState, todo]);
}
info

Questo esempio utilizza uno stato immutabile. Non è obbligatorio, ma consigliato. Consulta Why Immutability per ulteriori dettagli. Se desideri utilizzare uno stato mutabile, puoi fare in alternativa:

    final previousState = await future;
// Modifica la lista dei todo in modo mutabile.
previousState.add(todo);
// Notifica manualmente i listener.
ref.notifyListeners();

pro
  • Questo approccio dovrebbe funzionare indipendentemente dall'implementazione del server.
  • È necessaria solo una singola richiesta di rete.
contro
  • La cache locale potrebbe non corrispondere allo stato del server. Se un altro utente ha aggiunto un to-do, non lo vedremo.
  • Questo approccio potrebbe essere più complesso da implementare ed in realtà duplicare la logica del backend.

Andando oltre: Mostrare uno spinner e gestione dell'errore

Con tutto ciò che abbiamo visto finora, abbiamo un pulsante che effettua una richiesta POST quando viene premuto; e quando la richiesta è completata, l'interfaccia utente si aggiorna per riflettere le modifiche. Ma al momento, non c'è alcuna indicazione che la richiesta stia avvenendo, né alcuna informazione in caso di fallimento.

Un modo per farlo è memorizzare il Future restituito da addTodo nello stato locale del widget e quindi ascoltare quel future per mostrare un indicatore di caricamento o un messaggio di errore. Questo è uno scenario in cui flutter_hooks risulta utile. Ma naturalmente, è possibile utilizzare anche un StatefulWidget al suo posto.

Il seguente snippet mostra un indicatore di avanzamento mentre un'operazione è in corso. Se fallisce, rende il pulsante di colore rosso:

Un bottone che diventa rosso quando l&#39;operazione fallisce

class Example extends ConsumerStatefulWidget {
const Example({super.key});


ConsumerState<ConsumerStatefulWidget> createState() => _ExampleState();
}

class _ExampleState extends ConsumerState<Example> {
// L'operazione addTodo in sospeso. O null se nessuna è in attesa.
Future<void>? _pendingAddTodo;


Widget build(BuildContext context) {
return FutureBuilder(
// Ascoltiamo l'operazione in sospeso per aggiornare l'interfaccia utente di conseguenza.
future: _pendingAddTodo,
builder: (context, snapshot) {
// Calcoliamo se c'è uno stato di errore o meno.
// Controlliamo qui lo stato di ConnectionState per gestire quando l'operazione viene ripetuta.
final isErrored = snapshot.hasError && snapshot.connectionState != ConnectionState.waiting;

return Row(
children: [
ElevatedButton(
style: ButtonStyle(
// Se c'è stato un errore mostriamo il bottone in rosso
backgroundColor: MaterialStateProperty.all(
isErrored ? Colors.red : null,
),
),
onPressed: () {
// Assegniamo il future ritornato da 'addTodo' in una variabile
final future = ref
.read(todoListProvider.notifier)
.addTodo(Todo(description: 'This is a new todo'));

// Immagazziniamo il future nello stato locale
setState(() {
_pendingAddTodo = future;
});
},
child: const Text('Add todo'),
),
// L'operazione è in sospeso, mostriamo un indicatore di progresso
if (snapshot.connectionState == ConnectionState.waiting) ...[
const SizedBox(width: 8),
const CircularProgressIndicator(),
]
],
);
},
);
}
}