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:
| Pergunta | Provider + ChangeNotifier | Riverpod |
|---|---|---|
Dependência depende de BuildContext? | consumo na UI, sim | não |
| Override em teste | árvore Provider/fake | ProviderContainer e overrides |
| Estado assíncrono | modelado manualmente | AsyncValue oferece convenções |
| Curva inicial | menor | maior, com APIs próprias |
Checkpoint 6 — prove transições e descarte
Teste pelo menos:
- estado inicial;
- loading antes da conclusão;
- sucesso e lista vazia;
- falha conhecida;
- segunda chamada conforme a política;
- 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
| Sintoma | Causa |
|---|---|
ProviderNotFoundException | provider está abaixo da rota ou tipo diferente foi registrado |
setState/markNeedsBuild during build | load() foi disparado durante construção |
| busca perde itens | lista filtrada virou fonte de verdade mutável |
| resultado antigo sobrescreve novo | política de concorrência não foi implementada |
| notifier nunca é descartado | proprietá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.