Gerenciamento de estado em apps Flutter com Riverpod

1. Introdução ao Riverpod e seus diferenciais

O gerenciamento de estado é um dos desafios centrais no desenvolvimento de aplicações Flutter. Entre as diversas soluções disponíveis — Provider, BLoC, GetX, MobX — o Riverpod se destaca por oferecer uma abordagem moderna, segura e altamente testável.

Riverpod foi criado por Rémi Rousselet, o mesmo autor do Provider, mas resolve várias limitações do seu antecessor. Diferente do Provider, que depende da árvore de widgets para funcionar (context-dependent), o Riverpod é completamente independente do contexto do Flutter. Isso significa que os providers podem ser acessados de qualquer lugar, inclusive em serviços, repositórios e testes unitários.

Os conceitos fundamentais do Riverpod incluem:

  • Providers: unidades centrais que expõem e gerenciam estado. Cada provider é um objeto global que pode ser lido e escutado.
  • Ref: objeto que permite a um provider acessar outros providers, criando uma rede de dependências.
  • Escopo global: ao contrário do Provider, não é necessário empilhar providers na árvore de widgets.

Comparado ao BLoC, o Riverpod oferece menos boilerplate. Comparado ao GetX, oferece maior segurança de tipos e previsibilidade. A imutabilidade é incentivada naturalmente, e a testabilidade é um dos seus maiores trunfos.

2. Instalação e configuração inicial

Para começar, adicione a dependência principal ao arquivo pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.5.1
  riverpod_annotation: ^2.3.5

dev_dependencies:
  flutter_test:
    sdk: flutter
  riverpod_generator: ^2.4.0
  build_runner: ^2.4.9

O ponto de entrada da aplicação deve envolver toda a árvore de widgets com ProviderScope:

void main() {
  runApp(const ProviderScope(child: MeuApp()));
}

A estrutura de pastas recomendada para projetos com Riverpod segue uma organização por funcionalidades ou camadas:

lib/
├── main.dart
├── app.dart
├── core/
│   └── providers/
├── features/
│   ├── auth/
│   │   ├── providers/
│   │   ├── models/
│   │   └── screens/
│   └── tasks/
│       ├── providers/
│       ├── models/
│       └── screens/
└── shared/
    └── widgets/

3. Tipos de Providers essenciais

O Riverpod oferece diversos tipos de providers para diferentes necessidades.

Provider e StateProvider são os mais simples. Provider expõe um valor imutável (como uma instância de serviço), enquanto StateProvider permite atualizar o estado diretamente:

final contadorProvider = StateProvider<int>((ref) => 0);

// Uso no widget
final contador = ref.watch(contadorProvider);
ref.read(contadorProvider.notifier).state++;

FutureProvider é ideal para dados assíncronos, como requisições HTTP:

final usuarioProvider = FutureProvider<Usuario>((ref) async {
  final repository = ref.watch(usuarioRepositoryProvider);
  return repository.buscarUsuarioLogado();
});

StreamProvider lida com streams contínuas, como conexões em tempo real:

final mensagensProvider = StreamProvider<List<Mensagem>>((ref) {
  final chatService = ref.watch(chatServiceProvider);
  return chatService.ouvirMensagens();
});

StateNotifierProvider e NotifierProvider são usados para lógica de estado complexa, com métodos que modificam o estado de forma controlada:

class ListaTarefasNotifier extends StateNotifier<List<Tarefa>> {
  ListaTarefasNotifier() : super([]);

  void adicionar(Tarefa tarefa) {
    state = [...state, tarefa];
  }

  void remover(String id) {
    state = state.where((t) => t.id != id).toList();
  }
}

final listaTarefasProvider = StateNotifierProvider<ListaTarefasNotifier, List<Tarefa>>((ref) {
  return ListaTarefasNotifier();
});

4. Trabalhando com dependências e escopos

O modificador family permite criar providers parametrizados. Por exemplo, para buscar detalhes de um item específico:

final detalhesItemProvider = FutureProvider.family<Item, String>((ref, id) async {
  final repository = ref.watch(itemRepositoryProvider);
  return repository.buscarPorId(id);
});

// Consumo
final item = ref.watch(detalhesItemProvider('123'));

O modificador autoDispose libera recursos automaticamente quando nenhum widget está escutando o provider:

final temporizadorProvider = StreamProvider.autoDispose<int>((ref) {
  return Stream.periodic(const Duration(seconds: 1), (x) => x);
});

Providers podem depender de outros providers, formando uma cadeia de dependências:

final configProvider = Provider<Config>((ref) => Config());

final saudacaoProvider = Provider<String>((ref) {
  final config = ref.watch(configProvider);
  return config.idioma == 'pt' ? 'Olá' : 'Hello';
});

5. Consumo de estado na interface do usuário

Para escutar mudanças de estado na UI, use ConsumerWidget ou o widget Consumer:

class TelaContador extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final contador = ref.watch(contadorProvider);
    return Text('Valor: $contador');
  }
}

As diferenças entre os métodos de acesso:

  • ref.watch: escuta mudanças e reconstrói o widget quando o estado muda.
  • ref.read: lê o valor uma única vez, sem escutar. Use apenas em callbacks.
  • ref.listen: executa uma ação quando o estado muda, sem reconstruir o widget.

Para otimizar rebuilds, use o seletor select:

final nome = ref.watch(usuarioProvider.select((u) => u.nome));

Isso faz com que o widget reconstrua apenas quando nome mudar, ignorando outras propriedades.

6. Gerenciamento de estado assíncrono e erros

Quando usamos FutureProvider, o valor exposto é um AsyncValue, que pode estar em três estados: loading, error ou data.

class TelaUsuario extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncUsuario = ref.watch(usuarioProvider);

    return asyncUsuario.when(
      loading: () => const CircularProgressIndicator(),
      error: (erro, stack) => Text('Erro: $erro'),
      data: (usuario) => Text('Nome: ${usuario.nome}'),
    );
  }
}

Para combinar múltiplos FutureProviders, use ref.watch em um provider que depende de vários:

final perfilCompletoProvider = FutureProvider<PerfilCompleto>((ref) async {
  final usuario = await ref.watch(usuarioProvider.future);
  final preferencias = await ref.watch(preferenciasProvider.future);
  return PerfilCompleto(usuario: usuario, preferencias: preferencias);
});

7. Testes e boas práticas com Riverpod

A testabilidade é um dos maiores diferenciais do Riverpod. Para testar providers isoladamente, use ProviderContainer:

void main() {
  test('contadorProvider deve iniciar com 0', () {
    final container = ProviderContainer();
    final contador = container.read(contadorProvider);
    expect(contador, 0);
  });

  test('deve incrementar corretamente', () {
    final container = ProviderContainer();
    container.read(contadorProvider.notifier).state++;
    final contador = container.read(contadorProvider);
    expect(contador, 1);
  });
}

Para substituir dependências em testes, use ProviderScope.overrides:

final mockRepositoryProvider = Provider<UsuarioRepository>((ref) => MockRepository());

test('deve retornar usuário mockado', () {
  final container = ProviderContainer(
    overrides: [
      usuarioRepositoryProvider.overrideWithProvider(mockRepositoryProvider),
    ],
  );
  // ...
});

Boas práticas para escalabilidade:

  1. Modularização: separe providers por domínio ou funcionalidade.
  2. Injeção de dependências: use providers para expor serviços e repositórios.
  3. Evite lógica complexa nos widgets: mantenha a lógica de negócio nos Notifiers.
  4. Prefira NotifierProvider sobre StateNotifierProvider (mais moderno e flexível).
  5. Use autoDispose com moderação: apenas quando houver recursos que precisam ser liberados.

Riverpod não é apenas mais uma ferramenta de gerenciamento de estado — é uma plataforma completa que promove código limpo, testável e escalável. Sua adoção em projetos Flutter tem crescido rapidamente, e com razão: ele resolve problemas reais de arquitetura sem introduzir complexidade desnecessária.

Referências