Ein StatefulWidget mit setState zu einem Riverpod Notifier in Flutter migrieren
Schritt für Schritt vom widget-lokalen setState zu einem Riverpod-3.x-Notifier: klassifizieren, was das Widget wirklich verlässt, den Notifier schreiben, auf ConsumerWidget umstellen und die Fallstricke überstehen, die setState-Umsteiger treffen: Filterung per ==, erneute Ausführung von build() und unterschiedliche autoDispose-Voreinstellungen. Getestet mit Flutter 3.44, Dart 3.x und flutter_riverpod 3.3.2.
Einen Screen von setState auf einen Riverpod-Notifier umzustellen dauert etwa eine Stunde, sobald Sie es zweimal gemacht haben, und der größte Teil dieser Stunde entfällt auf die Entscheidung, was nicht umziehen soll. Diese Anleitung ist mit Flutter 3.44 (stabil, Mai 2026), Dart 3.x und flutter_riverpod 3.3.2 getestet, für die Codegenerierungs-Variante mit riverpod_generator 4.0.4 und riverpod_annotation 4.0.3. Was bricht, ist selten der Compiler: Die drei Punkte, die wirklich treffen, sind die Filterung der Benachrichtigungen per == in Riverpod 3.0 (die In-Place-Mutation einer Liste, die unter setState durchging, baut die Oberfläche jetzt stillschweigend nicht mehr neu auf), das erneute Ausführen von Notifier.build() dort, wo initState nur einmal lief, und die unterschiedlichen Voreinstellungen der automatischen Entsorgung bei generierten und handgeschriebenen Providern. Machen Sie es, wenn zwei Widgets denselben Zustand brauchen, oder wenn Sie die Logik ohne Widget testen wollen. Machen Sie es nicht für einen Screen, der ein einziges Boolean besitzt.
Warum dieser Zustand das Widget verlassen sollte
- Zwei Leser, eine Quelle. Ein Warenkorb-Badge in der
AppBarund ein Warenkorb-Screen zwei Routen weiter brauchen dieselben Positionen. MitsetStateheben Sie den Zustand entweder auf einen gemeinsamen Vorfahren und reichen Callbacks nach unten durch, oder Sie halten zwei Kopien und hoffen, dass sie übereinstimmen. - Die Logik wird unit-testbar. Ein
Notifierist ein gewöhnliches Dart-Objekt. Sie steuern ihn aus einemProviderContainer.test()in einem normalentest()-Block, ohnepumpWidget, ohneWidgetTesterund ohne Frame-Planung. - Der Zustand überlebt die Route, wenn Sie das wollen. Ein
NotifierProviderbehält seinen Wert über einNavigator.pophinweg, und genau das brauchen ein Warenkorb, ein Formularentwurf oder ein mehrstufiger Assistent. Widget-Zustand stirbt mit dem Element. - Mutationen bekommen Namen.
setState(() => _lines = [..._lines, line])über sechs Callbacks verstreut wird zucartProvider.notifier.add(line), und damit zu einer einzigen Stelle zum Protokollieren, Absichern oder Drosseln.
Nichts davon spricht dafür, alles zu verschieben. Ein TextEditingController, ein AnimationController, ein FocusNode, ein ScrollController und ein GlobalKey<FormState> gehören zum Widget und bleiben in einem State-Objekt.
Was bricht
| Bereich | Änderung | Schweregrad |
|---|---|---|
| Basisklasse des Widgets | StatefulWidget wird zu ConsumerWidget, oder zu ConsumerStatefulWidget, wenn Controller bleiben | hoch |
| In-Place-Mutation von Collections | Riverpod 3.0 filtert per ==; state.add(x) gefolgt von state = state baut nicht neu auf | hoch |
setState-Aufrufe | Ersetzt durch Zuweisung an state im Notifier | hoch |
initState | Wandert in Notifier.build(), das mehr als einmal laufen kann | mittel |
dispose | Wandert zu ref.onDispose, nur für Ressourcen des Providers | mittel |
| Lebensdauer des Zustands | Generierte Provider werden standardmäßig automatisch entsorgt, handgeschriebene nicht | mittel |
context nach einem await | context.mounted im Widget wird zu ref.mounted im Notifier | mittel |
| Widget-Tests | pumpWidget braucht eine ProviderScope-Hülle, sonst wirft jeder Lesezugriff | niedrig |
Checkliste vorab
- Flutter 3.44 stabil und Dart 3.x auf der Maschine und in der CI (
flutter --version). flutter_riverpod: ^3.3.2in derpubspec.yamlundProviderScopeumrunApp. Wenn Sie noch auf 2.x sind, erledigen Sie dieses Upgrade zuerst und getrennt: siehe die Migration von Riverpod 2.x auf Riverpod 3.0.- Entscheiden Sie jetzt über Codegenerierung, nicht auf halbem Weg. Codegenerierung braucht
riverpod_annotation: ^4.0.3sowieriverpod_generator: ^4.0.4undbuild_runnerunterdev_dependencies. riverpod_lintundcustom_lintin deranalysis_options.yamlaktiviert. Das findetref.readin einerbuild-Methode, den häufigsten Fehler dieser Migration.- Ein Widget-Test, der das aktuelle Verhalten des Screens festhält, bevor Sie ihn anfassen. Sie brauchen ein Rot/Grün-Signal, kein Bauchgefühl.
- Ein Branch. Das ist umkehrbar, aber nicht in drei kleinen Commits.
Der Ausgangspunkt
Ein Warenkorb-Screen, der alles in State hält, mit einem bis zum Kind durchgereichten Callback, damit das Badge sich aktualisieren kann:
// 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,
);
}
Migrationsschritte
-
Klassifizieren Sie jedes Feld des
State-Objekts. Teilen Sie sie auf Papier in zwei Listen, bevor Sie Code schreiben. Domänenzustand, den ein anderes Widget plausibel brauchen könnte (_lines,_isSubmitting), wandert in den Notifier. Framework-Objekte, die am Element dieses Widgets hängen (_couponController, Focus Nodes, Animation Controller, Formularschlüssel), bleiben. Verifikation: Jedes Feld steht in genau einer Liste, und nichts aus der Bleiben-Liste wird von einer anderen Route gelesen. -
Modellieren Sie den Zustand als einen unveränderlichen Wert. Zwei lose Felder werden zu einer Klasse, sodass eine einzige
state-Zuweisung den gesamten Screen beschreibt. Verifikation:dart analyzeist sauber und die Klasse hatcopyWith.// 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, ); } -
Schreiben Sie den
Notifier.build()liefert den Anfangszustand und ersetztinitState. Jeder früheresetState-Closure wird zu einer öffentlichen Methode, diestatezuweist. Verifikation: Die Datei kompiliert ohne jeden Verweis aufBuildContext,setStateoder einen Widget-Typ.// 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); } }Die Codegenerierungs-Form ist dieselbe Klasse mit abgeleitetem Provider:
// 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 } -
Testen Sie den Notifier per Unit-Test, bevor Sie ein einziges Widget anfassen. Das ist der Gewinn, also holen Sie ihn früh ab. Verifikation:
flutter test test/cart_notifier_test.dartläuft durch, ohne dass ein Widget gepumpt wird.// 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); }); -
Stellen Sie das Widget um. Wenn aus Schritt 1 nichts zurückbleibt, schrumpft
StatefulWidgetaufConsumerWidgetundbuildbekommt einWidgetRef. Da der Gutschein-Controller bleibt, wird dieser Screen stattdessen einConsumerStatefulWidget. Verifikation:flutter analyzemeldet null Probleme, einschließlich derriverpod_lint-Regeln.// 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), ); } } -
Wenden Sie die watch/read-Regel an jeder Aufrufstelle an.
ref.watchinbuild, weil Sie Rebuilds wollen.ref.read(provider.notifier)in Callbacks, weil Sie dort keine wollen. Niemalsref.watchin einemonPressed. Verifikation: Durchsuchen Sie die Datei nachref.read(und prüfen Sie, dass jeder Treffer in einem Callback oder einer asynchronen Methode steht, nie inbuild. -
Löschen Sie die durchgereichten Callbacks und lassen Sie das andere Widget direkt beobachten. Dieser Schritt zahlt die Migration. Das Badge bekommt die Anzahl nicht mehr über drei Konstruktoren, sondern liest den Provider selbst. Verifikation: Die Zwischen-Widgets deklarieren die entfernten Parameter nicht mehr, und das Hinzufügen eines Artikels im Warenkorb-Screen aktualisiert das Badge auf einer anderen Route.
// 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')); } }selectist hier entscheidend. Ohne es baut das Badge bei jedem Wechsel vonisSubmittingneu auf, was untersetStatenie passierte, weil es gar nicht in diesem Teilbaum lag. -
Verlagern Sie das Aufräumen von Provider-Ressourcen nach
ref.onDispose. Alles, was der Notifier erzeugt hat (einStreamSubscription, ein Timer, ein Socket), wird dort freigegeben, nicht imdisposedes Widgets. Verifikation: Schalten Sie den Screen weg und wieder hin und prüfen Sie, dass im Log keine doppelten Subscriptions auftauchen.@override CartState build() { final sub = PriceFeed.stream.listen(_onPriceChanged); ref.onDispose(sub.cancel); return CartState(lines: CartStorage.instance.load()); }
Verifikation
Arbeiten Sie diese Liste vor dem Merge ab:
flutter analyzemeldet null Probleme bei aktiviertemriverpod_lint.flutter testläuft durch, und die Widget-Tests umhüllen den Screen jetzt mit einemProviderScope. Ohne ihn wirft das ersteref.watchzur Laufzeit statt beim Kompilieren.- Der Screen baut auf, und jede frühere
setState-Interaktion aktualisiert weiterhin die Oberfläche. Klicken Sie jede durch; der Fehlermodus der==-Filterung (siehe unten) erzeugt keinen Fehler, nur ein eingefrorenes Widget. - Screen öffnen, schließen, wieder öffnen. Prüfen Sie, dass die Persistenz des Zustands Ihrer Absicht entspricht und nicht dem Zufall.
- Prüfung im Profile-Modus mit DevTools: Die Rebuild-Zahl des Parents sollte gleich oder niedriger sein als vorher. Ist sie gestiegen, fehlt ein
select.
Rollback-Plan
Diese Migration lässt sich mit git revert zurücknehmen, solange Sie sie in einem eigenen Branch gehalten haben, denn auf der Platte und über das Netz ändert sich nichts. Das Einzige, was ein Revert nicht wiederherstellt, ist Verhalten, das an der neuen Lebensdauer hing: Wenn Sie ausgeliefert haben und Nutzer sich daran gewöhnt haben, dass der Warenkorb eine Zurück-Navigation überlebt, verwirft die Rückkehr zum widget-lokalen Zustand ihn beim Pop stillschweigend. Setzen Sie den Code zurück und testen Sie die Navigationsflüsse erneut, nicht nur den Build.
Fallstricke, auf die wir gestoßen sind
In-Place-Mutation baute nicht mehr neu auf. Unter setState funktionierte _lines.add(line) im Closure, weil setState das Element unabhängig vom Inhalt als dirty markiert. Riverpod 3.0 vergleicht alten und neuen Zustand mit == und überspringt die Benachrichtigung, wenn beide gleich sind. Das hier tut also überhaupt nichts:
// 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
}
Bauen Sie immer einen neuen Wert, so wie in Schritt 3. Es ist dieselbe Gleichheitsfilterung, die zuschlägt, wenn ein StreamProvider in Riverpod 3.0 nichts mehr ausgibt. Hier trifft sie härter, wenn Ihre Zustandsklasse equatable oder einen freezed-Wertetyp verwendet, denn dann wird selbst ein korrekt neu gebautes Objekt mit unverändertem Inhalt herausgefiltert.
build() ist kein initState. initState läuft einmal pro Element. Notifier.build() läuft erneut, sobald sich eine beobachtete Abhängigkeit ändert, und setzt state auf den Rückgabewert zurück. Wenn Sie ref.watch(authProvider) in build() aufrufen, löscht eine Token-Erneuerung den Warenkorb. Verwenden Sie ref.read für Werte, die Sie nur beim Initialisieren brauchen, und reservieren Sie ref.watch in build() für Abhängigkeiten, die den Zustand tatsächlich zurücksetzen sollen.
Die Voreinstellungen der automatischen Entsorgung unterscheiden sich zwischen beiden Syntaxen. Ein handgeschriebenes NotifierProvider(CartNotifier.new) bleibt standardmäßig am Leben; Sie aktivieren die Entsorgung mit isAutoDispose: true. Ein generierter @riverpod-Provider wird standardmäßig automatisch entsorgt; Sie deaktivieren das mit @Riverpod(keepAlive: true). Teams, die beide Formen in einer Codebasis schreiben, bekommen einen Warenkorb, der sich auf manchen Screens selbst leert und auf anderen nicht, ohne jede erklärende Fehlermeldung.
mounted ist umgezogen. Im Widget verwenden Sie weiterhin context.mounted und die übliche mounted-Absicherung nach einer asynchronen Lücke. Im Notifier gibt es keinen BuildContext, daher lautet die Prüfung ref.mounted nach dem await. Wer sie vergisst, bekommt eine Ausnahme, wenn der Provider entsorgt wurde, während die Anfrage noch lief.
Controller gehören nicht in den Notifier. Einen TextEditingController in den Provider-Zustand zu legen wirkt aufgeräumt, bis der Provider das Widget überlebt und Sie in einen Controller tippen, dessen Listener längst weg sind. Lassen Sie die Regeln zur Freigabe von Controllern genau dort, wo sie waren.
Weiterführende Artikel
- Provider vs Riverpod vs Bloc für Flutter-State-Management 2026, falls Sie das Ziel noch auswählen.
- Von Riverpod 2.x auf Riverpod 3.0 migrieren, das Upgrade, das vor diesem hier kommt.
- Von FutureBuilder zu einem Riverpod-AsyncNotifier migrieren als asynchrones Gegenstück zu dieser Migration.
- Welches Riverpod-Paket Sie wirklich brauchen, denn
riverpodundflutter_riverpodsind nicht austauschbar. - Lade- und Fehlerzustände mit AsyncValue anzeigen, sobald der Notifier IO betreibt.
Quellen
- Was ist neu in Riverpod 3.0 für das vereinheitlichte
Ref,ref.mounted,ProviderContainer.test()und die Benachrichtigungsfilterung per==. - Riverpod-Provider-Referenz für den Vertrag von
Notifierundbuild(). - Automatische Entsorgung in Riverpod für
isAutoDisposeundref.keepAlive(). - Migration von 2.0 auf 3.0 für den Wegfall der
AutoDispose-Interfaces. - flutter_riverpod auf pub.dev und riverpod_generator auf pub.dev für die Versionen 3.3.2 und 4.0.4.
- Flutter Release Notes für die Basis Flutter 3.44 stabil.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.