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 {
// The POST request will return a List<Todo> matching the new application state
final response = await http.post(
Uri.https('your_api.com', '/todos'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);

// We decode the API response and convert it to a List<Todo>
List<Todo> newTodos = (jsonDecode(response.body) as List)
.cast<Map<String, Object?>>()
.map(Todo.fromJson)
.toList();

// We update the local cache to match the new state.
// This will notify all listeners.
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 {
// We don't care about the API response
await http.post(
Uri.https('your_api.com', '/todos'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);

// Once the post request is done, we can mark the local cache as dirty.
// This will cause "build" on our notifier to asynchronously be called again,
// and will notify listeners when doing so.
ref.invalidateSelf();

// (Optional) We can then wait for the new state to be computed.
// This ensures "addTodo" does not complete until the new state is available.
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 {
// We don't care about the API response
await http.post(
Uri.https('your_api.com', '/todos'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);

// We can then manually update the local cache. For this, we'll need to
// obtain the previous state.
// Caution: The previous state may still be loading or in error state.
// A graceful way of handling this would be to read `this.future` instead
// of `this.state`, which would enable awaiting the loading state, and
// throw an error if the state is in error state.
final previousState = await future;

// We can then update the state, by creating a new state object.
// This will notify all listeners.
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;
// Mutable the previous list of todos.
previousState.add(todo);
// Manually notify listeners.
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: WidgetStatePropertyAll(
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(),
]
],
);
},
);
}
}