Estado é informação ao longo do tempo

Campos de formulário, sessão autenticada, filtros, dados remotos e mensagens de erro têm ciclos de vida diferentes. Antes de escolher uma biblioteca, responda:

  • Quem é dono deste estado?
  • Quem precisa observá-lo?
  • Ele sobrevive à navegação?
  • É dado de origem ou pode ser derivado?
  • Como loading, empty e failure serão representados?

setState é adequado para estado local e curto. O problema começa quando widgets distantes dependem do mesmo dado ou quando efeitos assíncronos ficam misturados ao método build.

Provider e ChangeNotifier

Provider pode expor dependências e observar um ChangeNotifier:

final class CounterController extends ChangeNotifier {
  int _value = 0;
  int get value => _value;

  void increment() {
    _value++;
    notifyListeners();
  }
}
ChangeNotifierProvider(
  create: (_) => CounterController(),
  child: const CounterPage(),
)

Mantenha o controller pequeno, não exponha coleções mutáveis e evite notificações para mudanças que a tela não usa. ChangeNotifier é simples, mas estados grandes podem gerar dependências implícitas e reconstruções amplas.

Riverpod e estado explícito

Riverpod favorece dependências declaradas e testáveis. APIs exatas variam entre versões; use a documentação oficial da versão registrada no projeto. Conceitualmente, um notifier coordena um caso de uso e publica um estado:

final productsProvider =
    AsyncNotifierProvider<ProductsController, List<Product>>(
  ProductsController.new,
);

final class ProductsController extends AsyncNotifier<List<Product>> {
  @override
  Future<List<Product>> build() {
    return ref.read(getProductsProvider)();
  }

  Future<void> refresh() async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(ref.read(getProductsProvider).call);
  }
}

A UI consome loading, error e data sem lançar requisições dentro de build.

Estado imutável para fluxos complexos

Quando a tela precisa combinar paginação, filtros e seleção, use um estado fechado:

final class ProductsState {
  const ProductsState({
    this.items = const [],
    this.filter = '',
    this.isLoading = false,
    this.failure,
  });

  final List<Product> items;
  final String filter;
  final bool isLoading;
  final Object? failure;
}

Cada transição produz um novo valor. Isso melhora previsibilidade, logs e testes.

Evite estes atalhos

  • Requisições no build.
  • Estado de negócio em variáveis globais.
  • Um provider único para toda a aplicação.
  • Widgets conhecendo exceções de Dio ou Firebase.
  • Notifiers chamando navegação diretamente.
  • Manter em estado algo que pode ser calculado de outra fonte.

Testando transições

Teste estado inicial, sucesso, falha e concorrência. Uma atualização antiga não deve sobrescrever uma busca mais recente. Também valide o que ocorre quando o widget é descartado durante uma operação.

Prática guiada: torne o catálogo reativo sem esconder dependências

Continue no projeto do Módulo 4. A tela já recebe um ProductsController; primeiro você moverá essa entrega para Provider. Depois implementará a mesma fronteira em Riverpod em uma branch de estudo, não com os dois mecanismos ativos na aplicação.

Checkpoint 1 — instale Provider e defina o escopo

flutter pub add provider

No main.dart, envolva SalesApp com a instância criada no composition root:

void main() {
  final dependencies = AppDependencies();
  runApp(
    ChangeNotifierProvider.value(
      value: dependencies.productsController,
      child: const SalesApp(),
    ),
  );
}

Importe package:provider/provider.dart. Remova o parâmetro dependencies de SalesApp e abra ProductsPage sem passar controller.

Use .value porque a instância já existe. Quando o próprio Provider deve criar e descartar o objeto, use ChangeNotifierProvider(create: ...). Confundir os dois ciclos de vida pode descartar uma instância compartilhada cedo demais.

Checkpoint 2 — consuma somente onde é necessário

Remova o campo controller de ProductsPage. Em initState, não use context.watch, porque esse método não deve registrar rebuild. Agende a intenção após o primeiro frame:

@override
void initState() {
  super.initState();
  WidgetsBinding.instance.addPostFrameCallback((_) {
    if (!mounted) return;
    context.read<ProductsController>().load();
  });
}

No build:

final state = context.watch<ProductsController>().state;

return switch (state) {
  ProductsInitial() || ProductsLoading() =>
    const Center(child: CircularProgressIndicator()),
  ProductsLoaded(:final items) => ProductsContent(items: items),
  ProductsFailure(:final message) => ErrorView(
      message: message,
      onRetry: context.read<ProductsController>().load,
    ),
};

watch reconstrói quando o notifier avisa; read obtém a instância para uma ação pontual. Se um widget usa somente um campo, context.select pode reduzir rebuilds, mas só depois de medir uma necessidade real.

Checkpoint 3 — modele busca como estado derivado

Não crie uma segunda lista mutável. Acrescente ao controller:

String _query = '';
String get query => _query;

List<Product> get visibleProducts {
  final current = state;
  if (current is! ProductsLoaded) return const [];
  final normalized = _query.trim().toLowerCase();
  if (normalized.isEmpty) return current.items;
  return current.items
      .where((product) => product.name.toLowerCase().contains(normalized))
      .toList(growable: false);
}

void setQuery(String value) {
  if (_query == value) return;
  _query = value;
  notifyListeners();
}

Na página, adicione SearchBar(onChanged: controller.setQuery) e entregue visibleProducts ao conteúdo. A fonte de verdade continua sendo ProductsLoaded.items; a lista filtrada é calculada.

Checkpoint 4 — declare a política de concorrência

Para este curso, refresh concorrente será ignorado. Adicione:

bool _isLoading = false;

Future<void> load() async {
  if (_isLoading) return;
  _isLoading = true;
  state = const ProductsLoading();
  notifyListeners();
  try {
    state = ProductsLoaded(await _getProducts());
  } catch (_) {
    state = const ProductsFailure('Não foi possível carregar os produtos');
  } finally {
    _isLoading = false;
    notifyListeners();
  }
}

Ignorar, cancelar, enfileirar e permitir paralelismo são quatro políticas diferentes. O importante é escolher e testar, não deixar o timing decidir.

Crie um teste com um Completer<List<Product>>, chame load() duas vezes antes de completar e confirme que o repositório foi chamado uma única vez.

Checkpoint 5 — implemente a alternativa Riverpod isoladamente

Crie uma branch ou cópia de estudo e execute:

flutter pub add flutter_riverpod

Envolva a aplicação com ProviderScope. Declare os providers próximos da feature:

final productRepositoryProvider = Provider<ProductRepository>((ref) {
  throw UnimplementedError('Sobrescreva no composition root');
});

final getProductsProvider = Provider<GetProducts>((ref) {
  return GetProducts(ref.watch(productRepositoryProvider));
});

final productsProvider =
    AsyncNotifierProvider<ProductsNotifier, List<Product>>(
  ProductsNotifier.new,
);

final class ProductsNotifier extends AsyncNotifier<List<Product>> {
  @override
  Future<List<Product>> build() => ref.read(getProductsProvider)();

  Future<void> refresh() async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(ref.read(getProductsProvider).call);
  }
}

Na UI, um ConsumerWidget usa ref.watch(productsProvider) para renderizar e ref.read(productsProvider.notifier).refresh() para agir. No teste, ProviderContainer(overrides: [...]) substitui o repositório.

Não migre por estética. Compare:

PerguntaProvider + ChangeNotifierRiverpod
Dependência depende de BuildContext?consumo na UI, simnão
Override em testeárvore Provider/fakeProviderContainer e overrides
Estado assíncronomodelado manualmenteAsyncValue oferece convenções
Curva inicialmenormaior, com APIs próprias

Checkpoint 6 — prove transições e descarte

Teste pelo menos:

  1. estado inicial;
  2. loading antes da conclusão;
  3. sucesso e lista vazia;
  4. falha conhecida;
  5. segunda chamada conforme a política;
  6. nenhuma notificação depois de descarte.

Execute dart format, flutter analyze e flutter test. Registre no README qual abordagem ficará no projeto e justifique por tamanho do time, complexidade assíncrona e testabilidade.

Erros comuns

SintomaCausa
ProviderNotFoundExceptionprovider está abaixo da rota ou tipo diferente foi registrado
setState/markNeedsBuild during buildload() foi disparado durante construção
busca perde itenslista filtrada virou fonte de verdade mutável
resultado antigo sobrescreve novopolítica de concorrência não foi implementada
notifier nunca é descartadoproprietário e escopo não estão definidos

Prática autônoma: implemente uma política “última busca vence” usando um número de requisição e teste uma resposta antiga chegando depois da nova.

Aprofundamento: propriedade e ciclo de vida

Antes de escolher biblioteca, classifique o estado: efêmero de interface, estado de formulário, dados remotos, sessão ou estado derivado. O menor proprietário capaz de atender todos os consumidores costuma ser a fronteira mais segura. Estado global por conveniência aumenta acoplamento e amplia o impacto de cada mudança.

Defina quando o estado nasce, quem pode alterá-lo e quando é descartado. Uma assinatura de stream, timer ou request que sobrevive ao consumidor produz vazamento, atualização tardia ou erro de contexto. Cancelamento e dispose fazem parte do comportamento, não apenas da limpeza.

Modele concorrência explicitamente: ignorar uma segunda ação, cancelar a anterior, enfileirar ou permitir paralelismo são decisões diferentes. Um teste deve provar a opção escolhida.

Termos para revisar: source of truth, derived state, immutable state, notifier, provider scope, disposal e race condition. Consulte a referência do curso.