Cache de dados Riverpod e Ciclo de vida de Providers: Guia completo

Tempo de leitura: 13 minutes

Se você já usa o Riverpod há algum tempo, provavelmente sabe como declarar provedores e usá-los dentro de seus widgets.

Você também deve saber que os provedores são globais, mas o estado deles não.

Mas como os provedores realmente funcionam nos bastidores?

Você já imaginou:

  • Quando os provedores são inicializados?
  • Quando e como eles são descartados?
  • O que acontece quando um widget escuta um provedor?
  • Qual é o ciclo de vida de um provedor?
  • Como o Riverpod faz o cache de dados?

Este artigo responderá a todas essas perguntas e ajudará você:

  • entender melhor a relação entre provedores e widgets
  • aprenda como funciona o cache de dados e como ele está relacionado aos eventos do ciclo de vida do provedor
  • escolha o comportamento de cache de dados mais apropriado de acordo com suas necessidades

Também ajudará você a ver o Riverpod como ele é: um cache reativo e uma estrutura de ligação de dados que ajuda a resolver problemas complexos (como cache de dados) com código simples.

O cache de dados é um tópico amplo, por isso abordaremos a invalidação de cache e outras técnicas avançadas em um artigo de acompanhamento.

Mas, por enquanto, já temos muito o que abordar!

Preparar? Vamos! 🚀

Este artigo pressupõe que você já conheça o básico. Se você é novo no Riverpod, leia isto primeiro: Flutter Riverpod 2.x: O Guia Definitivo

 

Noções básicas do ciclo de vida do Provider: exemplo de aplicativo de contador

Se quisermos compreender profundamente como funcionam os provedores, há muitas coisas a considerar.

Antes de chegarmos ao assunto principal, vamos revisar o básico usando um aplicativo de contador simples como exemplo:

final counterStateProvider = StateProvider<int>((ref) {
  return 0;
});

Como podemos ver, counterStateProvider é declarado como uma variável global, e dentro do corpo do provedor retornamos seu estado inicial (o valor inteiro 0).

Então, podemos criar o seguinte ConsumerWidget:

class CounterWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 1. observe o provedor e reconstrua quando o valor mudar
    final counter = ref.watch(counterStateProvider);
    return ElevatedButton(
      // 2. use o valor
      child: Text('Value: $counter'),
      // 3. incrementa o contador quando o botão é pressionado
      onPressed: () => ref.read(counterStateProvider.notifier).state++,
    );
  }
}

Dentro do método build, observamos o provider declarado acima e o usamos para mostrar o valor do contador dentro de um widget Text.

E devemos lembrar de envolver nosso aplicativo com um ProviderScope:

void main() {
  runApp(ProviderScope(
    child: MaterialApp(
      home: CounterWidget(),
    ),
  ));
}

O exemplo é simples, mas você consegue descobrir quando o provedor é inicializado?

Reserve um minuto para pensar sobre isso. Vou esperar. ⏱

 

Quando o provedor é inicializado?

Aqui estão duas respostas plausíveis:

  1. Quando chamamos runApp dentro de main e atribuímos a ele um ProviderScope de nível superior
  2. Quando o widget CounterWidget é montado pela primeira vez e chamamos ref.watch dentro do método de construção

Para obter a resposta correta, não precisamos adivinhar.

Na verdade, podemos adicionar algumas instruções de impressão ao nosso código:

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  if (kDebugMode) {
    print('Dentro do main');
  }
  runApp(const ProviderScope(
    child: MaterialApp(
      home: CounterWidget(),
    ),
  ));
}

final counterStateProvider = StateProvider<int>((ref) {
  if (kDebugMode) {
    print('counterStateProvider inicializado');
  }
  return 0;
});

class CounterWidget extends ConsumerWidget {
  const CounterWidget({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    if (kDebugMode) {
      print('Construção do CounterWidget');
    }
    final counter = ref.watch(counterStateProvider);
    return ElevatedButton(
      child: Text('Valor: $counter'),
      onPressed: () => ref.read(counterStateProvider.notifier).state++,
    );
  }
}

 

Se executarmos este código no Dartpad, veremos esta saída no log do console:

Dentro do main
Construção do CounterWidget
counterStateProvider inicializado

Isso significa que counterStateProvider é inicializado apenas quando chamamos ref.watch(counterStateProvider) dentro do widget.

E isso ocorre porque todos os provedores Riverpod é lazy-loaded.

Mas vamos nos aprofundar, já que algumas coisas interessantes acontecem nos bastidores.

Usar instruções de impressão e pontos de interrupção de depuração é uma ótima maneira de explorar o comportamento de tempo de execução do seu aplicativo. Diagnosticei e corrigi inúmeros bugs com essas duas ferramentas, portanto, certifique-se de usá-las também. 👍

 

Registrando um Listener

Vamos dar uma olhada mais de perto no CounterWidget:

class CounterWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 1. observe o provedor e reconstrua quando o valor mudar
    final counter = ref.watch(counterStateProvider);
    return ElevatedButton(
      // 2. use o valor
      child: Text('Value: $counter'),
      // 3. aumente o contador quando o botão for pressionado
      onPressed: () => ref.read(counterStateProvider.notifier).state++,
    );
  }
}

Quando chamamos ref.watch(counterStateProvider) dentro do método build, duas coisas acontecem:

  • Obtemos o estado do provedor (o valor do contador) para que possamos mostrá-lo na UI
  • O CounterWidget se torna um ouvinte do counterStateProvider, para que possa se reconstruir quando o estado do provedor mudar (como quando o incrementamos dentro do retorno de chamada onPressed)

Algo interessante também acontece dentro do provedor:

  • O estado do provedor é inicializado quando o primeiro ouvinte é registrado
  • Cada vez que o estado muda, todos os ouvintes serão notificados para que possam se atualizar/reconstruir

 

O padrão observável

O aplicativo de contador acima tinha apenas um provedor e um widget de ouvinte.

Mas os provedores podem ter mais de um ouvinte.

E os provedores também podem ouvir outros provedores.

Na verdade, Riverpod se baseia no padrão observável, onde um provedor é observável e outros provedores ou widgets são os observadores (ouvintes):

Tanto ref.watch() quanto ref.listen() podem ser usados para registrar-se como ouvinte de um provedor. Isso contrasta com ref.read(), que faz apenas uma leitura única e não registra um ouvinte.

 

Quando os provedores são descartados?

Até agora, aprendemos que os provedores são lazy-loaded e só são inicializados quando um ouvinte é anexado (via ref.watch ou ref.listen).

Mas o que acontece se o CounterWidget for removido da árvore de widgets?

Bem, isso depende se declaramos o provedor com autoDispose (ou o novo sinalizador keepAlive).

Em outras palavras, se declararmos o provedor assim:

// sem autoDispose
final counterStateProvider = StateProvider<int>((ref) {
  return 0;
});

Em seguida, o provedor manterá o estado e o manterá na memória até que o ProviderScope envolvente seja descartado (o que acontece quando o usuário ou sistema operacional mata o aplicativo se tivermos um ProviderScope de nível superior dentro de main).

Mas também poderíamos declarar o provedor assim:

// com autoDispose
final counterStateProvider = StateProvider.autoDispose<int>((ref) {
  return 0;
});

Nesse caso, o modificador autoDispose informa ao Riverpod que o estado do provedor deve ser descartado assim que o último ouvinte for removido.

Deixe-me dizer isso novamente:

  • Se declararmos um provedor sem autoDispose, seu estado permanecerá ativo até que o ProviderScope envolvente seja descartado (a menos que o descartemos explicitamente de alguma outra forma – mais sobre isso abaixo)
  • Se declararmos um provedor com autoDispose, seu estado será descartado assim que o último ouvinte for removido (normalmente quando o widget for desmontado)

E agora que entendemos o básico, vamos explorar um exemplo mais interessante. 👇

 

Um exemplo mais complexo: Lista de itens → Página de detalhes

No mobile, é muito comum mostrar uma lista de itens e navegar até uma página de detalhes quando selecionamos um item.

Aqui está um exemplo baseado na API jsonplaceholder:

O código-fonte completo deste exemplo pode ser encontrado neste repositório GitHub.

Para buscar os dados necessários para mostrar a UI, podemos criar um PostsRepository como este:

// Repositório de posts baseado no cliente http Dio.
// Veremos como usar [CancelToken] mais tarde.
class PostsRepository {
  PostsRepository({required this.dio});
  final Dio dio;

  // Busca todas as postagens
  Future<List<Post>> fetchPosts({CancelToken? cancelToken}) { ... }

  // Busca uma postagem específica por ID
  Future<Post> fetchPost(int postId, {CancelToken? cancelToken}) { ... }
}

E podemos usar a nova sintaxe @riverpod para criar algumas funções para buscar os dados:

// usado para gerar um postsRepositoryProvider (como um provedor regular)
@riverpod
PostsRepository postsRepository(PostsRepositoryRef ref) {
  return PostsRepository(dio: ref.watch(dioProvider));
}

// usado para gerar um fetchPostsProvider (como um FutureProvider)
@riverpod
Future<List<Post>> fetchPosts(FetchPostsRef ref) {
  return ref.watch(postsRepositoryProvider).fetchPosts();
}

// usado para gerar um fetchPostProvider (como FutureProvider.family)
@riverpod
Future<Post> fetchPost(FetchPostRef ref, int postId) {
  return ref.watch(postsRepositoryProvider).fetchPost(postId);
}

A sintaxe acima depende do novo pacote Riverpod Generator. Para saber mais, leia: Como gerar automaticamente seus provedores com Flutter Riverpod Generator.

Então, podemos observar o fetchPostsProvider dentro de um widget PostsList (estilo omitido para simplificar):

class PostsList extends ConsumerWidget {
  const PostsList({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // observe o fetchPostsProvider
    final postsAsync = ref.watch(fetchPostsProvider);
    // use-o para mostrar uma lista de postagens
    return postsAsync.when(
      data: (posts) => ListView.separated(
        itemCount: posts.length,
        itemBuilder: (context, index) {
          final post = posts[index];
          return ListTile(
            leading: Text(post.id.toString(),
            title: Text(post.title),
            // ao tocar, navegue até [PostDetailsScreen]
            onTap: () => Navigator.of(context).push(MaterialPageRoute(
              builder: (context) => PostDetailsScreen(postId: post.id),
            )),
          );
        },
        separatorBuilder: (context, index) => const Divider(),
      ),
      loading: () => const Center(child: CircularProgressIndicator()),
      error: (e, st) => Center(child: Text(e.toString())),
    );
  }
}

E quando um item da lista é tocado, podemos navegar para uma nova página onde mostramos os detalhes da postagem:

class PostDetailsScreen extends ConsumerWidget {
  const PostDetailsScreen({super.key, required this.postId});
  final int postId;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // observa o fetchPostProvider, passando o postId como argumento
    final postsAsync = ref.watch(fetchPostProvider(postId));
    return Scaffold(
      appBar: AppBar(title: Text('Post $postId')),
      body: postsAsync.when(
        // mostra os detalhes da postagem
        data: (post) => Column(
          mainAxisSize: MainAxisSize.max,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(post.title, style: Theme.of(context).textTheme.titleLarge),
            const SizedBox(height: 32),
            Text(post.body),
          ],
        ),
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (e, st) => Center(child: Text(e.toString())),
      ),
    );
  }
}

Observe como PostDetailsScreen pega o postId como argumento e o usa ao buscar os dados do fetchPostProvider. Alternativamente, poderíamos ter passado o objeto Post diretamente do construtor ListView (sem usar fetchPostProvider). Isso é mais eficiente, mas não pode ser usado para navegação baseada em URL, pois o URL é apenas uma string e não podemos codificar objetos personalizados dentro do caminho.

Mais uma vez, podemos usar os widgets acima para criar essas duas páginas e navegar para frente e para trás:

A seguir, vamos descobrir quando os provedores são inicializados e descartados.

 

Quando os provedores Riverpod são descartados?

Para descobrir o que acontece em tempo de execução, vamos adicionar algumas instruções print ao nosso provedor:

// usado para gerar um fetchPostProvider (como FutureProvider)
@riverpod
Future<Post> fetchPost(FetchPostRef ref, int postId) {
  print('init: fetchPost($postId)');
  ref.onDispose(() => print('dispose: fetchPost($postId)'));
  return ref.watch(postsRepositoryProvider).fetchPost(postId);
}

E também podemos imprimir algo no método fetchPost do PostsRepository:

Future<Post> fetchPost(int postId, {CancelToken? cancelToken}) async {
  print('dio: fetchPost($postId)');
  // aguarda dio.get(...)
}

Se executarmos o aplicativo e selecionarmos o primeiro item da lista, este log aparecerá:

flutter: init: fetchPost(1)
flutter: dio: fetchPost(1)

E se navegarmos de volta, obteremos isto:

flutter: dispose: fetchPost(1)

Da mesma forma, se selecionarmos outro item e voltarmos novamente, obteremos mais três registros:

flutter: init: fetchPost(2) <-- toque no segundo item
flutter: dio: fetchPost(2)
flutter: dispose: fetchPost(2) <-- navegar de volta

Se quisermos, podemos selecionar o primeiro item mais uma vez e navegar de volta.

E quando terminarmos, o log completo do console deverá ficar assim:

flutter: init: fetchPost(1) <-- toque no primeiro item
flutter: dio: fetchPost(1)
flutter: dispose: fetchPost(1) <-- navegar de volta
flutter: init: fetchPost(2) <-- toque no segundo item
flutter: dio: fetchPost(2)
flutter: dispose: fetchPost(2) <-- navegar de volta
flutter: init: fetchPost(1) <-- toque no primeiro item
flutter: dio: fetchPost(1)
flutter: dispose: fetchPost(1) <-- navegar de volta

A saída do console nos diz que:

  • cada vez que selecionamos um item, buscamos (e armazenamos em cache) os dados da rede
  • assim que navegamos de volta, os dados em cache são descartados

Em outras palavras, os provedores são descartados automaticamente por padrão se usarmos a sintaxe @riverpod, o que significa que não mantemos o estado quando ele não for mais necessário:

// se usarmos @riverpod, o estado será descartado após a remoção do último ouvinte
@riverpod
Future<Post> fetchPost(FetchPostRef ref, int postId) {
  print('init: fetchPost($postId)');
  ref.onDispose(() => print('dispose: fetchPost($postId)'));
  return ref.watch(postsRepositoryProvider).fetchPost(postId);
}

Quando assistimos um FutureProvider acima pela primeira vez, ele buscará os dados da rede e os armazenará em cache para uso posterior. Se observarmos o provedor novamente (antes de ele ser descartado), uma nova solicitação de rede não será feita e os dados armazenados em cache serão retornados.

 

Mantendo o estado com keepAlive

Se quisermos manter o estado, podemos anotar nosso provedor com keepAlive: true:

// defina keepAlive: true para manter o estado após a remoção do último ouvinte
@Riverpod(keepAlive: true)
Future<Post> fetchPost(FetchPostRef ref, int postId) {
  print('init: fetchPost($postId)');
  ref.onDispose(() => print('dispose: fetchPost($postId)'));
  return ref.watch(postsRepositoryProvider).fetchPost(postId);
}

// declare também este provedor com keepAlive para evitar um erro de compilação:
@Riverpod(keepAlive: true)
PostsRepository postsRepository(PostsRepositoryRef ref) {
  return PostsRepository(dio: ref.watch(dioProvider));
}

Se executarmos o aplicativo agora, podemos navegar até o primeiro item:

flutter: init: fetchPost(1)
flutter: dio: fetchPost(1)

Mas se voltarmos à lista de itens, nenhum registro de descarte será impresso no console.

 

E se selecionarmos o primeiro item novamente, os dados estarão disponíveis imediatamente e nenhuma interface de carregamento será apresentada.

Isso ocorre porque com keepAlive, Riverpod retém cada objeto Post que buscamos e pode devolvê-lo imediatamente se solicitarmos novamente o mesmo postId.

 

Como funciona o keepAlive?

Usando keepAlive: true, implementamos um cache que nunca expira (enquanto o aplicativo estiver em execução).

Por outro lado, se usarmos keepAlive: false (que é o padrão), o Riverpod armazenará os dados em cache apenas enquanto houver ouvintes ativos. Mas assim que o último ouvinte for removido, os dados serão descartados.

Em outras palavras:

  • com keepAlive: true, os dados permanecem na memória para sempre (mesmo quando não são mais necessários)
  • com keepAlive: false, buscamos novamente os dados da rede todas as vezes (depois que o provedor for descartado)

Estes parecem dois extremos de um amplo espectro:

Mas será que podemos de alguma forma personalizar a estratégia de cache e obter o melhor dos dois mundos?

 

Cache com tempo limite

Se desejar, podemos chamar ref.keepAlive() dentro do provedor e usá-lo para definir um cache baseado em tempo limite.

Veja como podemos implementar isso:

@riverpod
Future<Post> fetchPost(FetchPostRef ref, int postId) {
  // alguns logs para fins de monitoramento
  print('init: fetchPost($postId)');
  ref.onCancel(() => print('cancel: fetchPost($postId)'));
  ref.onResume(() => print('resume: fetchPost($postId)'));
  ref.onDispose(() => print('dispose: fetchPost($postId)'));
  // obtenha o [KeepAliveLink]
  final link = ref.keepAlive();
  // um temporizador a ser usado pelos retornos de chamada abaixo
  Timer? timer;
  // Um objeto do package:dio que permite cancelar solicitações http
  final cancelToken = CancelToken();
  // Quando o provedor for destruído, cancele a solicitação http e o cronômetro
  ref.onDispose(() {
    timer?.cancel();
    cancelToken.cancel();
  });
  // Quando o último ouvinte for removido, inicie um cronômetro para descartar os dados armazenados em cache
  ref.onCancel(() {
    // iniciar um cronômetro de 30 segundos
    timer = Timer(const Duration(seconds: 30), () {
      // descartar no tempo limite
      link.close();
    });
  });
  // Se o provedor for escutado novamente após ter sido pausado, cancele o cronômetro
  ref.onResume(() {
    timer?.cancel();
  });
  // Busque nossos dados e passe nosso `cancelToken` para que o cancelamento funcione
  return ref
      .watch(postsRepositoryProvider)
      .fetchPost(postId, cancelToken: cancelToken);
}

O código acima usa os retornos de chamada do ciclo de vida onDispose, onCancel e onResume para implementar alguma lógica de tempo limite personalizada e cancelar quaisquer solicitações de rede em andamento se não precisarmos mais da resposta (útil se navegarmos antes que a solicitação seja concluída).

Mais uma vez, vamos usar este aplicativo de exemplo como referência:

Se executarmos o aplicativo com as alterações acima, podemos selecionar o primeiro item para revelar os detalhes da postagem:

flutter: init: fetchPost(1)
flutter: dio: fetchPost(1)

Assim que navegamos de volta, obtemos isto:

flutter: cancel: fetchPost(1)

E se selecionarmos o mesmo item novamente antes do tempo limite de 30 segundos, obteremos isto:

flutter: resume: fetchPost(1)

Caso contrário, obtemos isto:

flutter: dispose: fetchPost(1)

Isso significa que não buscaremos os dados da postagem novamente se abrirmos a página de detalhes novamente dentro do limite de 30 segundos.

 

Retornos de chamada do ciclo de vida do provedor

No exemplo de cache acima, usamos três retornos de chamada do ciclo de vida do provedor para executar a lógica de descarte personalizada em tempo de execução.

No total, existem cinco retornos de chamada diferentes que podemos usar:

  • ref.onDispose: acionado logo antes do provedor ser destruído
  • ref.onCancel: acionado quando o último listener do provedor é removido
  • ref.onResume: acionado quando um provedor é escutado novamente após ter sido pausado
  • ref.onAddListener: acionado sempre que um novo listener é adicionado ao provedor
  • ref.onRemoveListener: acionado sempre que um novo listener é removido do provedor

Todos esses retornos de chamada e muitos outros métodos úteis estão documentados na documentação da API da classe Ref.

Para entender melhor quando esses callbacks são acionados, considere este diagrama que representa todos os estados possíveis de um provedor:

Veja como funciona:

Não armazenado em cache

  • Quando o aplicativo é iniciado, o estado de cada provedor é “não armazenado em cache” por padrão, pois não há ouvintes ativos.
  • Quando um ouvinte é adicionado, o provedor passa para o estado “ativo” e permanece lá enquanto houver pelo menos um ouvinte.

Armazenado em cache (ativo)

  • Quando adicionamos mais ouvintes, os dados armazenados em cache são retornados imediatamente
  • Quando removemos ouvintes, nada muda, desde que reste um ouvinte
  • Quando o último ouvinte é removido, o provedor verifica o sinalizador keepAlive. Se keepAlive == false, ele retorna ao estado “sem cache”. Se keepAlive == true, ele vai para o estado “pausado”.Em cache (pausado)
  • Quando o KeepAliveLink é fechado, ele retorna ao estado “sem cache”.
  • Quando um ouvinte é adicionado, ele retorna ao estado “ativo”.

Nota: também é possível que um provedor passe de “não armazenado em cache” para “pausado” se fizermos uma leitura única com ref.read(). Depois disso, o provedor permanece no estado “pausado” (se keepAlive for verdadeiro) ou retorna para “não armazenado em cache” (se keepAlive for falso).

O diagrama acima é um modelo conceitual de como os provedores fazem a transição entre diferentes estados. Isso não leva em conta alguns casos extremos que podem acontecer quando as dependências mudam, mas para nossos propósitos, servirá.

Com este modelo mental, deverá ser mais fácil compreender como os prestadores se comportam.

E se você quiser inspecionar seu comportamento em tempo de execução, adicione algumas instruções print dentro de qualquer provedor que você deseja monitorar:

ref.onCancel(() => print('cancel: fetchPost($postId)'));
ref.onResume(() => print('resume: fetchPost($postId)'));
ref.onDispose(() => print('dispose: fetchPost($postId)'));

Depois, você pode brincar com seu aplicativo e se divertir olhando os logs do console. 😎

 

Respostas para perguntas comuns

Até agora, cobrimos muito terreno e exploramos como funciona o cache de dados no Riverpod.

Antes de encerrarmos, vamos tentar responder a algumas perguntas comuns. 👇

 

Quando devemos definir keepAlive: false?

Podemos usar keepAlive para personalizar o comportamento de cache dentro de qualquer provedor (não apenas FutureProvider).

Por padrão, anotamos os provedores com a sintaxe @riverpod, que é a mesma que @Riverpod(keepAlive: false).

Este é um valor padrão sensato porque garante que um provedor seja descartado assim que todos os seus ouvintes forem removidos (sem desperdiçar memória).

Aqui estão alguns outros exemplos onde devemos usar keepAlive: false (ou o equivalente autoDispose):

  • Quando criamos um notificador para gerenciar o estado de um único widget. Neste cenário, podemos usar a sintaxe padrão @riverpod para garantir que o NotifierProvider seja descartado assim que o widget for desmontado.
  • Quando usamos um StreamProvider e queremos que a conexão do stream seja fechada assim que todos os widgets do ouvinte forem removidos. Podemos fazer isso com autoDispose.

Para obter mais detalhes sobre as novas classes Notifier no Riverpod 2.0, leia: Como usar Notifier e AsyncNotifier com o novo Flutter Riverpod Generator

 

Quando devemos definir keepAlive: true?

Se quisermos manter algum estado do aplicativo na memória e tê-lo sempre disponível enquanto o aplicativo estiver em execução, devemos usar @Riverpod(keepAlive: true).

Outro exemplo é quando usamos Riverpod para injeção de dependência e criamos provedores de longa duração para objetos que queremos instanciar apenas uma vez (como wrappers para APIs de terceiros como FirebaseAuth, SharedPreferences etc.).

 

Conclusão

Chegamos ao final deste artigo, então vamos fazer um resumo dos pontos mais importantes:

  • Os provedores são  lazy loaded e são inicializados quando os usamos pela primeira vez
  • Quando o último ouvinte de um provedor for removido, o provedor será descartado (se keepAlive for falso) ou entrará em estado de pausa (se keepAlive for verdadeiro)
  • Podemos usar retornos de chamada de ciclo de vida como onDispose, onCancel, onResume para implementar alguma lógica de cache personalizada
  • Também podemos usar onDispose para cancelar quaisquer solicitações de rede em voo que não sejam mais necessárias

A conclusão mais importante é que Riverpod é uma estrutura de cache reativo e ligação de dados (exatamente como dizem os documentos). E ajuda a resolver problemas complexos com código simples usando APIs flexíveis com padrões razoáveis.

Como o cache de dados é um tópico amplo, abordaremos a invalidação do cache e algumas outras técnicas avançadas em um artigo subsequente.