Migrate a setState StatefulWidget to a Riverpod Notifier in Flutter
A step-by-step move from widget-local setState to a Riverpod 3.x Notifier: classify what actually leaves the widget, write the Notifier, convert to ConsumerWidget, and survive the == filtering, build() re-entry, and autoDispose defaults that bite setState refugees. Tested on Flutter 3.44, Dart 3.x, flutter_riverpod 3.3.2.
Moving one screen off setState and onto a Riverpod Notifier takes about an hour once you have done it twice, and most of that hour is deciding what should not move. This guide is tested on Flutter 3.44 (stable, May 2026), Dart 3.x, and flutter_riverpod 3.3.2, with riverpod_generator 4.0.4 and riverpod_annotation 4.0.3 for the code-generation variant. What breaks is rarely the compiler: the three things that bite are Riverpod 3.0 filtering notifications with == (so the in-place list mutation you got away with under setState now silently stops rebuilding the UI), Notifier.build() re-running where initState ran once, and auto-disposal defaulting differently for generated and hand-written providers. Do it when two widgets need the same state, or when you want to test the logic without pumping a widget. Do not do it for a screen that owns one boolean.
Why this state should leave the widget
- Two readers, one source. A cart badge in the
AppBarand a cart screen two routes away both need the line items. WithsetStateyou either lift the state to a common ancestor and drill callbacks down, or you keep two copies and hope they agree. - The logic becomes unit-testable. A
Notifieris a plain Dart object. You can drive it from aProviderContainer.test()in a normaltest()block, with nopumpWidget, noWidgetTester, and no frame scheduling. - State outlives the route when you want it to. A
NotifierProviderkeeps its value across aNavigator.pop, which is what a cart, a draft form, or a multi-step wizard actually needs. Widget state dies with the element. - Mutations get names.
setState(() => _lines = [..._lines, line])scattered across six callbacks becomescartProvider.notifier.add(line), which is one place to log, guard, or throttle.
None of that argues for moving everything. A TextEditingController, an AnimationController, a FocusNode, a ScrollController, and a GlobalKey<FormState> all belong to the widget and should stay in a State object.
What breaks
| Area | Change | Severity |
|---|---|---|
| Widget base class | StatefulWidget becomes ConsumerWidget, or ConsumerStatefulWidget if controllers stay | high |
| In-place collection mutation | Riverpod 3.0 filters with ==; state.add(x) then state = state does not rebuild | high |
setState call sites | Replaced by assigning state inside the Notifier | high |
initState | Moves into Notifier.build(), which can run more than once | medium |
dispose | Moves to ref.onDispose for provider-owned resources only | medium |
| State lifetime | Generated providers auto-dispose by default; hand-written ones do not | medium |
context after an await | context.mounted inside the widget becomes ref.mounted inside the notifier | medium |
| Widget tests | pumpWidget needs a ProviderScope wrapper or every read throws | low |
Pre-flight checklist
- Flutter 3.44 stable and Dart 3.x on the machine and in CI (
flutter --version). flutter_riverpod: ^3.3.2inpubspec.yaml, andProviderScopewrappingrunApp. If you are still on 2.x, do that upgrade first and separately: see migrating from Riverpod 2.x to Riverpod 3.0.- Decide codegen or no codegen now, not halfway through. Codegen needs
riverpod_annotation: ^4.0.3plusriverpod_generator: ^4.0.4andbuild_runnerunderdev_dependencies. riverpod_lintandcustom_lintenabled inanalysis_options.yaml. It catchesref.readin abuildmethod, which is the single most common mistake in this migration.- A widget test that pins the current behaviour of the screen before you touch it. You want a red/green signal, not a vibe.
- A branch. This is reversible, but not in three small commits.
The starting point
A cart screen holding everything in State, with a callback drilled into a child so the badge can update:
// 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,
);
}
Migration steps
-
Classify every field in the
Stateobject. Split them into two lists on paper before writing code. Domain state that another widget could plausibly need (_lines,_isSubmitting) moves to the notifier. Framework objects tied to this widget’s element (_couponController, focus nodes, animation controllers, form keys) stay. Verify: every field is in exactly one list, and nothing in the “stays” list is read by another route. -
Model the state as one immutable value. Two loose fields become one class so a single
state =assignment describes the whole screen. Verify:dart analyzeis clean and the class hascopyWith.// 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, ); } -
Write the
Notifier.build()returns the initial state and replacesinitState. Each formersetStateclosure becomes a public method that assignsstate. Verify: the file compiles with no reference toBuildContext,setState, or any widget type.// 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); } }The code-generation form is the same class with the provider inferred:
// 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 } -
Unit-test the notifier before touching a single widget. This is the payoff, so collect it early. Verify:
flutter test test/cart_notifier_test.dartpasses with no widget pumped.// 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); }); -
Convert the widget. If nothing from step 1 stayed behind,
StatefulWidgetcollapses toConsumerWidgetandbuildgains aWidgetRef. Because the coupon controller stayed, this screen becomes aConsumerStatefulWidgetinstead. Verify:flutter analyzereports zero issues, including theriverpod_lintrules.// 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), ); } } -
Apply the watch/read rule at every call site.
ref.watchinbuildbecause you want rebuilds.ref.read(provider.notifier)in callbacks because you do not. Neverref.watchinside anonPressed. Verify: grep the file forref.read(and confirm every hit is inside a callback or an async method, never inbuild. -
Delete the drilled callbacks and let the other widget watch directly. This is the step that pays for the migration. The badge stops receiving a count through three constructors and reads the provider itself. Verify: the intermediate widgets no longer declare the removed parameters, and adding an item from the cart screen updates the badge on another 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')); } }selectmatters here. Without it the badge rebuilds wheneverisSubmittingflips, which undersetStateit never did because it was not in that widget’s subtree at all. -
Move provider-owned cleanup to
ref.onDispose. Anything the notifier created (aStreamSubscription, a timer, a socket) is released there, not in the widget’sdispose. Verify: toggle the screen off and on and confirm no duplicate subscriptions in the logs.@override CartState build() { final sub = PriceFeed.stream.listen(_onPriceChanged); ref.onDispose(sub.cancel); return CartState(lines: CartStorage.instance.load()); }
Verification
Run this list before you merge:
flutter analyzereports zero issues withriverpod_lintenabled.flutter testpasses, and the widget tests now wrap the screen in aProviderScope. Without it, the firstref.watchthrows at runtime rather than at compile time.- The screen builds and every former
setStateinteraction still updates the UI. Tap through each one; the==filtering failure mode (see below) produces no error, just a frozen widget. - Push the screen, pop it, push it again. Confirm the state persistence matches what you intended, not what happened by accident.
- Profile-mode check with DevTools: the rebuild count on the parent should be the same or lower than before. If it went up, you are missing a
select.
Rollback plan
This migration is reversible with git revert as long as you kept it on its own branch, because nothing changes on disk or over the wire. The one thing revert does not restore is behaviour that depended on the new lifetime: if you shipped and users grew accustomed to a cart surviving a back navigation, reverting to widget-local state silently drops it on pop. Roll back the code and re-test the navigation flows, not just the build.
Gotchas we hit
In-place mutation stopped rebuilding. Under setState, _lines.add(line) inside the closure worked, because setState marks the element dirty regardless of what changed. Riverpod 3.0 compares the old and new state with == and skips notification when they are equal, so this does nothing at all:
// 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
}
Always build a new value, as step 3 does. This is the same equality filtering that catches people out when a Riverpod 3.0 StreamProvider stops emitting. It bites harder here if your state class uses equatable or a freezed value type, because then even a correctly rebuilt object with unchanged contents will be filtered.
build() is not initState. initState runs once per element. Notifier.build() re-runs whenever a watched dependency changes, and it resets state to whatever it returns. If you ref.watch(authProvider) inside build(), a token refresh wipes the cart. Use ref.read for values you only want at initialization, and reserve ref.watch in build() for dependencies that genuinely should reset the state.
Auto-disposal defaults differ between the two syntaxes. A hand-written NotifierProvider(CartNotifier.new) is kept alive by default; you opt in with isAutoDispose: true. A generated @riverpod provider is auto-disposed by default; you opt out with @Riverpod(keepAlive: true). Teams that write both forms in one codebase get a cart that empties itself on some screens and not others, with no error to explain it.
mounted moved. Inside the widget you still use context.mounted and the usual mounted guard after an async gap. Inside the notifier there is no BuildContext, so the check is ref.mounted after the await. Forgetting it throws when the provider was disposed while the request was in flight.
Controllers do not belong in the notifier. Putting a TextEditingController in provider state looks tidy until the provider outlives the widget and you are typing into a controller whose listeners are gone. Keep the controller disposal rules exactly where they were.
Related
- Provider vs Riverpod vs Bloc for Flutter state management in 2026 if you are still choosing a destination.
- Migrate from Riverpod 2.x to Riverpod 3.0, the upgrade to do before this one.
- Migrate from FutureBuilder to a Riverpod AsyncNotifier for the async equivalent of this migration.
- Which Riverpod package do you actually need, because
riverpodandflutter_riverpodare not interchangeable. - Showing loading and error states with AsyncValue once the notifier starts doing IO.
Sources
- What’s new in Riverpod 3.0 for the unified
Ref,ref.mounted,ProviderContainer.test(), and the==notification filtering. - Riverpod providers reference for the
Notifierandbuild()contract. - Riverpod automatic disposal for
isAutoDisposeandref.keepAlive(). - Migrating from 2.0 to 3.0 for the
AutoDisposeinterface removal. - flutter_riverpod on pub.dev and riverpod_generator on pub.dev for the 3.3.2 and 4.0.4 version pins.
- Flutter release notes for the 3.44 stable baseline.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.