Migre um StatefulWidget com setState para um Notifier do Riverpod no Flutter
Um caminho passo a passo do setState local do widget para um Notifier do Riverpod 3.x: classifique o que realmente sai do widget, escreva o Notifier, converta para ConsumerWidget e sobreviva ao filtro por ==, à reexecução do build() e aos padrões de autoDispose que pegam quem vem do setState. Testado no Flutter 3.44, Dart 3.x e flutter_riverpod 3.3.2.
Tirar uma tela do setState e colocá-la em um Notifier do Riverpod leva cerca de uma hora depois que você já fez isso duas vezes, e a maior parte dessa hora é decidir o que não deve migrar. Este guia foi testado no Flutter 3.44 (estável, maio de 2026), Dart 3.x e flutter_riverpod 3.3.2, com riverpod_generator 4.0.4 e riverpod_annotation 4.0.3 para a variante com geração de código. O que quebra raramente é o compilador: as três coisas que pegam são o Riverpod 3.0 filtrando notificações com == (então a mutação de lista no lugar que passava batido com setState agora para de reconstruir a interface silenciosamente), o Notifier.build() executando de novo onde o initState rodava uma única vez, e o descarte automático tendo padrões diferentes para providers gerados e escritos à mão. Faça isso quando dois widgets precisarem do mesmo estado, ou quando você quiser testar a lógica sem montar um widget. Não faça para uma tela que é dona de um único booleano.
Por que esse estado deve sair do widget
- Dois leitores, uma fonte. Um selo de carrinho no
AppBare uma tela de carrinho a duas rotas de distância precisam das mesmas linhas. ComsetStatevocê ou eleva o estado para um ancestral comum e empurra callbacks para baixo, ou mantém duas cópias e torce para que concordem. - A lógica fica testável em unidade. Um
Notifieré um objeto Dart comum. Você consegue controlá-lo a partir de umProviderContainer.test()em um blocotest()normal, sempumpWidget, semWidgetTestere sem agendamento de frames. - O estado sobrevive à rota quando você quer. Um
NotifierProvidermantém seu valor através de umNavigator.pop, que é exatamente o que um carrinho, um formulário em rascunho ou um assistente de várias etapas precisam. O estado do widget morre com o elemento. - As mutações ganham nome.
setState(() => _lines = [..._lines, line])espalhado por seis callbacks viracartProvider.notifier.add(line), que é um único lugar para registrar em log, proteger ou limitar.
Nada disso defende mover tudo. Um TextEditingController, um AnimationController, um FocusNode, um ScrollController e um GlobalKey<FormState> pertencem ao widget e devem ficar em um objeto State.
O que quebra
| Área | Mudança | Severidade |
|---|---|---|
| Classe base do widget | StatefulWidget vira ConsumerWidget, ou ConsumerStatefulWidget se controladores ficarem | alta |
| Mutação de coleção no lugar | O Riverpod 3.0 filtra com ==; state.add(x) seguido de state = state não reconstrói | alta |
Chamadas a setState | Substituídas pela atribuição de state dentro do Notifier | alta |
initState | Migra para Notifier.build(), que pode rodar mais de uma vez | média |
dispose | Vai para ref.onDispose, apenas para recursos pertencentes ao provider | média |
| Tempo de vida do estado | Providers gerados descartam automaticamente por padrão; os escritos à mão não | média |
context depois de um await | context.mounted dentro do widget vira ref.mounted dentro do notifier | média |
| Testes de widget | pumpWidget precisa de um ProviderScope em volta ou toda leitura lança exceção | baixa |
Checklist de preparação
- Flutter 3.44 estável e Dart 3.x na máquina e no CI (
flutter --version). flutter_riverpod: ^3.3.2nopubspec.yaml, eProviderScopeenvolvendo orunApp. Se você ainda está no 2.x, faça essa atualização antes e separadamente: veja a migração do Riverpod 2.x para o Riverpod 3.0.- Decida agora se vai usar geração de código ou não, não no meio do caminho. A geração de código precisa de
riverpod_annotation: ^4.0.3maisriverpod_generator: ^4.0.4ebuild_runneremdev_dependencies. riverpod_lintecustom_linthabilitados noanalysis_options.yaml. Ele pegaref.readdentro de um métodobuild, que é o erro mais comum desta migração.- Um teste de widget que fixe o comportamento atual da tela antes de você mexer nela. Você quer um sinal vermelho/verde, não uma impressão.
- Uma branch. Isso é reversível, mas não em três commits pequenos.
O ponto de partida
Uma tela de carrinho guardando tudo em State, com um callback empurrado para um filho para que o selo consiga atualizar:
// Flutter 3.44, Dart 3.x -- before
class CartScreen extends StatefulWidget {
const CartScreen({super.key});
@override
State<CartScreen> createState() => _CartScreenState();
}
class _CartScreenState extends State<CartScreen> {
List<CartLine> _lines = const [];
bool _isSubmitting = false;
final _couponController = TextEditingController();
@override
void initState() {
super.initState();
_lines = CartStorage.instance.load();
}
@override
void dispose() {
_couponController.dispose();
super.dispose();
}
void _add(CartLine line) {
setState(() => _lines = [..._lines, line]);
}
void _setQuantity(String sku, int quantity) {
setState(() {
_lines = [
for (final l in _lines)
if (l.sku == sku) l.copyWith(quantity: quantity) else l,
];
});
}
Future<void> _submit() async {
setState(() => _isSubmitting = true);
await CheckoutApi.submit(_lines);
if (!mounted) return;
setState(() => _isSubmitting = false);
}
@override
Widget build(BuildContext context) => CartView(
lines: _lines,
isSubmitting: _isSubmitting,
couponController: _couponController,
onQuantityChanged: _setQuantity,
);
}
Passos da migração
-
Classifique cada campo do objeto
State. Divida-os em duas listas no papel antes de escrever código. O estado de domínio que outro widget poderia plausivelmente precisar (_lines,_isSubmitting) vai para o notifier. Os objetos de framework atrelados ao elemento deste widget (_couponController, focus nodes, controladores de animação, chaves de formulário) ficam. Verificação: cada campo está em exatamente uma lista, e nada da lista “fica” é lido por outra rota. -
Modele o estado como um único valor imutável. Dois campos soltos viram uma classe para que uma única atribuição de
statedescreva a tela inteira. Verificação:dart analyzeestá limpo e a classe temcopyWith.// Flutter 3.44, Dart 3.x class CartState { const CartState({this.lines = const [], this.isSubmitting = false}); final List<CartLine> lines; final bool isSubmitting; int get itemCount => lines.fold(0, (sum, l) => sum + l.quantity); CartState copyWith({List<CartLine>? lines, bool? isSubmitting}) => CartState( lines: lines ?? this.lines, isSubmitting: isSubmitting ?? this.isSubmitting, ); } -
Escreva o
Notifier. Obuild()devolve o estado inicial e substitui oinitState. Cada antigo closure desetStatevira um método público que atribuistate. Verificação: o arquivo compila sem nenhuma referência aBuildContext,setStateou qualquer tipo de widget.// flutter_riverpod 3.3.2 -- no codegen import 'package:flutter_riverpod/flutter_riverpod.dart'; final cartProvider = NotifierProvider<CartNotifier, CartState>( CartNotifier.new, ); class CartNotifier extends Notifier<CartState> { @override CartState build() => CartState(lines: CartStorage.instance.load()); void add(CartLine line) { state = state.copyWith(lines: [...state.lines, line]); } void setQuantity(String sku, int quantity) { state = state.copyWith( lines: [ for (final l in state.lines) if (l.sku == sku) l.copyWith(quantity: quantity) else l, ], ); } Future<void> submit() async { state = state.copyWith(isSubmitting: true); await CheckoutApi.submit(state.lines); if (!ref.mounted) return; state = state.copyWith(isSubmitting: false); } }A forma com geração de código é a mesma classe com o provider inferido:
// riverpod_annotation 4.0.3, riverpod_generator 4.0.4 @Riverpod(keepAlive: true) class Cart extends _$Cart { @override CartState build() => CartState(lines: CartStorage.instance.load()); // ...same methods } -
Faça testes de unidade do notifier antes de tocar em um único widget. Essa é a recompensa, então colha cedo. Verificação:
flutter test test/cart_notifier_test.dartpassa sem nenhum widget montado.// flutter_riverpod 3.3.2 test('setQuantity replaces the matching line', () { final container = ProviderContainer.test(); container.read(cartProvider.notifier).add(const CartLine(sku: 'A', quantity: 1)); container.read(cartProvider.notifier).setQuantity('A', 3); expect(container.read(cartProvider).itemCount, 3); }); -
Converta o widget. Se nada do passo 1 ficou para trás, o
StatefulWidgetse reduz aConsumerWidgete obuildganha umWidgetRef. Como o controlador do cupom ficou, esta tela vira umConsumerStatefulWidget. Verificação:flutter analyzereporta zero problemas, incluindo as regras doriverpod_lint.// Flutter 3.44, flutter_riverpod 3.3.2 -- after class CartScreen extends ConsumerStatefulWidget { const CartScreen({super.key}); @override ConsumerState<CartScreen> createState() => _CartScreenState(); } class _CartScreenState extends ConsumerState<CartScreen> { final _couponController = TextEditingController(); @override void dispose() { _couponController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final cart = ref.watch(cartProvider); return CartView( lines: cart.lines, isSubmitting: cart.isSubmitting, couponController: _couponController, onQuantityChanged: (sku, qty) => ref.read(cartProvider.notifier).setQuantity(sku, qty), ); } } -
Aplique a regra watch/read em cada ponto de chamada.
ref.watchnobuildporque você quer reconstruções.ref.read(provider.notifier)nos callbacks porque não quer. Nunca useref.watchdentro de umonPressed. Verificação: procureref.read(no arquivo e confirme que cada ocorrência está dentro de um callback ou de um método assíncrono, nunca nobuild. -
Apague os callbacks empurrados para baixo e deixe o outro widget observar diretamente. Este é o passo que paga a migração. O selo para de receber uma contagem através de três construtores e lê o provider por conta própria. Verificação: os widgets intermediários não declaram mais os parâmetros removidos, e adicionar um item pela tela do carrinho atualiza o selo em outra rota.
// flutter_riverpod 3.3.2 class CartBadge extends ConsumerWidget { const CartBadge({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final count = ref.watch(cartProvider.select((s) => s.itemCount)); return Badge(label: Text('$count')); } }O
selectimporta aqui. Sem ele o selo reconstrói sempre queisSubmittingmuda, o que comsetStatenunca acontecia porque ele nem estava naquela subárvore. -
Mova a limpeza pertencente ao provider para
ref.onDispose. Tudo o que o notifier criou (umStreamSubscription, um timer, um socket) é liberado ali, não nodisposedo widget. Verificação: alterne a tela e confirme que não há assinaturas duplicadas nos logs.@override CartState build() { final sub = PriceFeed.stream.listen(_onPriceChanged); ref.onDispose(sub.cancel); return CartState(lines: CartStorage.instance.load()); }
Verificação
Rode esta lista antes de fazer o merge:
flutter analyzereporta zero problemas com oriverpod_linthabilitado.flutter testpassa, e os testes de widget agora envolvem a tela em umProviderScope. Sem ele, o primeiroref.watchlança exceção em tempo de execução, não em tempo de compilação.- A tela constrói e cada interação que antes usava
setStatecontinua atualizando a interface. Passe por cada uma; o modo de falha do filtro por==(veja abaixo) não produz erro nenhum, apenas um widget congelado. - Empilhe a tela, saia dela e empilhe de novo. Confirme que a persistência do estado é a que você pretendia, não a que aconteceu por acidente.
- Checagem em modo profile com o DevTools: a contagem de reconstruções do pai deve ser igual ou menor que antes. Se subiu, está faltando um
select.
Plano de rollback
Esta migração é reversível com git revert desde que você a tenha mantido na própria branch, porque nada muda em disco nem na rede. A única coisa que o revert não restaura é o comportamento que dependia do novo tempo de vida: se você já publicou e os usuários se acostumaram com o carrinho sobrevivendo a uma navegação de volta, reverter para o estado local do widget o descarta silenciosamente no pop. Reverta o código e teste de novo os fluxos de navegação, não só o build.
Problemas que encontramos
A mutação no lugar parou de reconstruir. Com setState, _lines.add(line) dentro do closure funcionava, porque o setState marca o elemento como sujo independentemente do que mudou. O Riverpod 3.0 compara o estado antigo com o novo usando == e pula a notificação quando são iguais, então isso não faz absolutamente nada:
// broken on flutter_riverpod 3.x
void add(CartLine line) {
state.lines.add(line); // mutates the same List instance
state = state; // identical, == is true, no listeners notified
}
Sempre construa um valor novo, como faz o passo 3. É o mesmo filtro por igualdade que pega as pessoas quando um StreamProvider do Riverpod 3.0 para de emitir. Aqui ele pega mais forte se a sua classe de estado usa equatable ou um tipo de valor do freezed, porque aí até um objeto reconstruído corretamente com conteúdo inalterado será filtrado.
build() não é initState. O initState roda uma vez por elemento. O Notifier.build() roda de novo sempre que uma dependência observada muda, e redefine state para o que quer que ele retorne. Se você usar ref.watch(authProvider) dentro do build(), uma renovação de token apaga o carrinho. Use ref.read para valores que você só quer na inicialização, e reserve o ref.watch no build() para dependências que genuinamente devem redefinir o estado.
Os padrões de descarte automático diferem entre as duas sintaxes. Um NotifierProvider(CartNotifier.new) escrito à mão fica vivo por padrão; você adere com isAutoDispose: true. Um provider gerado com @riverpod é descartado automaticamente por padrão; você sai com @Riverpod(keepAlive: true). Times que escrevem as duas formas na mesma base de código acabam com um carrinho que se esvazia sozinho em algumas telas e em outras não, sem nenhum erro que explique isso.
O mounted mudou de lugar. Dentro do widget você continua usando context.mounted e a habitual proteção com mounted depois de um intervalo assíncrono. Dentro do notifier não existe BuildContext, então a checagem é ref.mounted depois do await. Esquecer disso lança exceção quando o provider foi descartado enquanto a requisição estava em andamento.
Controladores não pertencem ao notifier. Colocar um TextEditingController no estado do provider parece organizado até o provider sobreviver ao widget e você estar digitando em um controlador cujos listeners já não existem. Mantenha as regras de descarte de controladores exatamente onde estavam.
Leituras relacionadas
- Provider vs Riverpod vs Bloc para gerenciamento de estado no Flutter em 2026 se você ainda está escolhendo o destino.
- Migrar do Riverpod 2.x para o Riverpod 3.0, a atualização a fazer antes desta.
- Migrar do FutureBuilder para um AsyncNotifier do Riverpod para o equivalente assíncrono desta migração.
- Qual pacote do Riverpod você realmente precisa, porque
riverpodeflutter_riverpodnão são intercambiáveis. - Mostrar estados de carregamento e erro com AsyncValue quando o notifier começar a fazer IO.
Fontes
- Novidades do Riverpod 3.0 para o
Refunificado,ref.mounted,ProviderContainer.test()e o filtro de notificações por==. - Referência de providers do Riverpod para o contrato de
Notifierebuild(). - Descarte automático no Riverpod para
isAutoDisposeeref.keepAlive(). - Migrando de 2.0 para 3.0 para a remoção das interfaces
AutoDispose. - flutter_riverpod no pub.dev e riverpod_generator no pub.dev para as versões fixadas 3.3.2 e 4.0.4.
- Notas de versão do Flutter para a linha de base 3.44 estável.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.