Перенос StatefulWidget с setState на Notifier из Riverpod во Flutter
Пошаговый переход от локального setState виджета к Notifier из Riverpod 3.x: как разделить состояние, написать Notifier, перейти на ConsumerWidget и пережить фильтрацию по ==, повторный вызов build() и разные значения autoDispose по умолчанию. Проверено на Flutter 3.44, Dart 3.x и flutter_riverpod 3.3.2.
Перенос одного экрана с setState на Notifier из Riverpod занимает около часа, если вы уже делали это дважды, и большая часть этого часа уходит на решение, что переносить не нужно. Руководство проверено на Flutter 3.44 (стабильный, май 2026), Dart 3.x и flutter_riverpod 3.3.2, а для варианта с генерацией кода на riverpod_generator 4.0.4 и riverpod_annotation 4.0.3. Ломается редко компилятор: по-настоящему кусаются три вещи. Riverpod 3.0 фильтрует уведомления по == (то есть изменение списка на месте, которое сходило с рук при setState, теперь молча перестаёт перестраивать интерфейс), Notifier.build() вызывается повторно там, где initState выполнялся один раз, и автоматическое освобождение по умолчанию работает по-разному для сгенерированных и написанных вручную провайдеров. Делайте это, когда одно и то же состояние нужно двум виджетам или когда вы хотите тестировать логику без виджета. Не делайте этого ради экрана, который владеет одним булевым флагом.
Почему это состояние должно покинуть виджет
- Два читателя, один источник. Значок корзины в
AppBarи экран корзины через два маршрута нуждаются в одних и тех же позициях. СsetStateвы либо поднимаете состояние к общему предку и протаскиваете колбэки вниз, либо держите две копии и надеетесь, что они совпадут. - Логика становится пригодной для модульных тестов.
Notifierэто обычный объект Dart. Им можно управлять изProviderContainer.test()в обычном блокеtest(), безpumpWidget, безWidgetTesterи без планирования кадров. - Состояние переживает маршрут, когда вам это нужно.
NotifierProviderсохраняет значение послеNavigator.pop, а именно это и требуется корзине, черновику формы или многошаговому мастеру. Состояние виджета умирает вместе с элементом. - У изменений появляются имена.
setState(() => _lines = [..._lines, line]), разбросанный по шести колбэкам, превращается вcartProvider.notifier.add(line), то есть в единственное место для журналирования, проверок или ограничения частоты.
Ничто из этого не оправдывает перенос всего подряд. TextEditingController, AnimationController, FocusNode, ScrollController и GlobalKey<FormState> принадлежат виджету и должны остаться в объекте State.
Что ломается
| Область | Изменение | Серьёзность |
|---|---|---|
| Базовый класс виджета | StatefulWidget становится ConsumerWidget или ConsumerStatefulWidget, если контроллеры остаются | высокая |
| Изменение коллекции на месте | Riverpod 3.0 фильтрует по ==; state.add(x) с последующим state = state не вызывает перестроение | высокая |
Вызовы setState | Заменяются присваиванием state внутри Notifier | высокая |
initState | Переезжает в Notifier.build(), который может выполниться не один раз | средняя |
dispose | Переходит в ref.onDispose, только для ресурсов провайдера | средняя |
| Время жизни состояния | Сгенерированные провайдеры освобождаются автоматически, написанные вручную нет | средняя |
context после await | context.mounted внутри виджета становится ref.mounted внутри notifier | средняя |
| Тесты виджетов | pumpWidget требует обёртки ProviderScope, иначе каждое чтение выбрасывает исключение | низкая |
Подготовительный список
- Flutter 3.44 стабильный и Dart 3.x на машине и в CI (
flutter --version). flutter_riverpod: ^3.3.2вpubspec.yamlиProviderScope, оборачивающийrunApp. Если вы всё ещё на 2.x, сначала выполните это обновление отдельно: смотрите переход с Riverpod 2.x на Riverpod 3.0.- Решите про генерацию кода сейчас, а не на полпути. Для неё нужны
riverpod_annotation: ^4.0.3, а такжеriverpod_generator: ^4.0.4иbuild_runnerвdev_dependencies. riverpod_lintиcustom_lintвключены вanalysis_options.yaml. Они ловятref.readвнутри методаbuild, а это самая частая ошибка этого переноса.- Тест виджета, фиксирующий текущее поведение экрана до того, как вы его тронете. Нужен сигнал красный/зелёный, а не ощущение.
- Отдельная ветка. Перенос обратим, но не тремя маленькими коммитами.
Отправная точка
Экран корзины, который держит всё в State, с колбэком, протащенным до дочернего виджета, чтобы значок мог обновляться:
// 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,
);
}
Шаги переноса
-
Разберите каждое поле объекта
State. Разделите их на два списка на бумаге, прежде чем писать код. Доменное состояние, которое правдоподобно может понадобиться другому виджету (_lines,_isSubmitting), переезжает в notifier. Объекты фреймворка, привязанные к элементу этого виджета (_couponController, focus node, контроллеры анимации, ключи формы), остаются. Проверка: каждое поле ровно в одном списке, и ничто из списка “остаётся” не читается другим маршрутом. -
Опишите состояние одним неизменяемым значением. Два разрозненных поля превращаются в класс, чтобы одно присваивание
stateописывало весь экран. Проверка:dart analyzeчист, у класса естьcopyWith.// 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, ); } -
Напишите
Notifier.build()возвращает начальное состояние и заменяетinitState. Каждое прежнее замыканиеsetStateстановится публичным методом, который присваиваетstate. Проверка: файл компилируется без единой ссылки наBuildContext,setStateили любой тип виджета.// 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); } }Вариант с генерацией кода это тот же класс с выведенным провайдером:
// 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 } -
Покройте notifier модульными тестами до того, как тронете хоть один виджет. Ради этого всё и затевалось, поэтому забирайте выигрыш сразу. Проверка:
flutter test test/cart_notifier_test.dartпроходит без единого отрисованного виджета.// 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); }); -
Переведите виджет. Если после шага 1 в виджете ничего не осталось,
StatefulWidgetсжимается доConsumerWidget, аbuildполучаетWidgetRef. Поскольку контроллер купона остался, этот экран становитсяConsumerStatefulWidget. Проверка:flutter analyzeсообщает о нуле замечаний, включая правилаriverpod_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), ); } } -
Примените правило watch/read в каждой точке вызова.
ref.watchвbuild, потому что перестроения нужны.ref.read(provider.notifier)в колбэках, потому что там они не нужны. Никогда не вызывайтеref.watchвнутриonPressed. Проверка: найдите в файлеref.read(и убедитесь, что каждое вхождение находится в колбэке или асинхронном методе, но не вbuild. -
Удалите протащенные колбэки и позвольте другому виджету наблюдать напрямую. Именно этот шаг окупает перенос. Значок перестаёт получать счётчик через три конструктора и читает провайдер сам. Проверка: промежуточные виджеты больше не объявляют удалённые параметры, а добавление товара с экрана корзины обновляет значок на другом маршруте.
// 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')); } }selectздесь важен. Без него значок перестраивается при каждом переключенииisSubmitting, чего приsetStateне было вовсе, потому что он даже не находился в этом поддереве. -
Перенесите очистку ресурсов провайдера в
ref.onDispose. Всё, что создал notifier (StreamSubscription, таймер, сокет), освобождается там, а не вdisposeвиджета. Проверка: уйдите с экрана и вернитесь, убедитесь, что в журнале нет дублирующихся подписок.@override CartState build() { final sub = PriceFeed.stream.listen(_onPriceChanged); ref.onDispose(sub.cancel); return CartState(lines: CartStorage.instance.load()); }
Проверка
Пройдите этот список перед слиянием:
flutter analyzeсообщает о нуле замечаний при включённомriverpod_lint.flutter testпроходит, а тесты виджетов теперь оборачивают экран вProviderScope. Без него первый жеref.watchвыбросит исключение во время выполнения, а не при компиляции.- Экран строится, и каждое взаимодействие, раньше использовавшее
setState, по-прежнему обновляет интерфейс. Пройдите по всем; отказ из-за фильтрации по==(смотрите ниже) не даёт никакой ошибки, только застывший виджет. - Откройте экран, закройте и откройте снова. Убедитесь, что сохранение состояния соответствует замыслу, а не случайности.
- Проверка в режиме profile через DevTools: число перестроений родителя должно остаться прежним или снизиться. Если оно выросло, не хватает
select.
План отката
Перенос обратим через git revert, если вы держали его в отдельной ветке, потому что на диске и в сети ничего не меняется. Откат не восстановит только поведение, зависевшее от нового времени жизни: если вы уже выпустили версию и пользователи привыкли, что корзина переживает возврат назад, откат к локальному состоянию виджета молча теряет её при pop. Верните код и заново проверьте сценарии навигации, а не только сборку.
Подводные камни, на которые мы наткнулись
Изменение на месте перестало вызывать перестроение. При setState вызов _lines.add(line) внутри замыкания работал, потому что setState помечает элемент грязным независимо от того, что изменилось. Riverpod 3.0 сравнивает старое и новое состояние через == и пропускает уведомление, если они равны, поэтому вот это не делает ровно ничего:
// 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
}
Всегда стройте новое значение, как в шаге 3. Это та же самая фильтрация по равенству, которая застаёт врасплох, когда StreamProvider в Riverpod 3.0 перестаёт выдавать события. Здесь она бьёт сильнее, если ваш класс состояния использует equatable или тип-значение из freezed, потому что тогда даже корректно пересозданный объект с неизменным содержимым будет отфильтрован.
build() это не initState. initState выполняется один раз на элемент. Notifier.build() выполняется заново при каждом изменении наблюдаемой зависимости и сбрасывает state в то, что он вернёт. Если вызвать ref.watch(authProvider) внутри build(), обновление токена сотрёт корзину. Используйте ref.read для значений, нужных только при инициализации, а ref.watch в build() оставьте для зависимостей, которые действительно должны сбрасывать состояние.
Значения автоматического освобождения по умолчанию различаются в двух синтаксисах. Написанный вручную NotifierProvider(CartNotifier.new) по умолчанию остаётся живым; включить освобождение можно через isAutoDispose: true. Сгенерированный провайдер @riverpod по умолчанию освобождается автоматически; отключить это можно через @Riverpod(keepAlive: true). Команды, использующие обе формы в одной кодовой базе, получают корзину, которая на одних экранах очищается сама, а на других нет, и никакой ошибки, объясняющей это, не будет.
mounted переехал. Внутри виджета вы по-прежнему используете context.mounted и обычную проверку mounted после асинхронного разрыва. Внутри notifier нет BuildContext, поэтому проверка это ref.mounted после await. Забыв о ней, вы получите исключение, если провайдер был освобождён, пока запрос был в полёте.
Контроллерам не место в notifier. Положить TextEditingController в состояние провайдера выглядит аккуратно ровно до момента, когда провайдер переживёт виджет и вы будете печатать в контроллер, слушателей у которого уже нет. Оставьте правила освобождения контроллеров ровно там, где они были.
Связанные материалы
- Provider против Riverpod против Bloc для управления состоянием во Flutter в 2026, если вы ещё выбираете цель.
- Переход с Riverpod 2.x на Riverpod 3.0, обновление, которое стоит сделать раньше этого.
- Переход с FutureBuilder на AsyncNotifier из Riverpod, асинхронный аналог этого переноса.
- Какой пакет Riverpod вам действительно нужен, потому что
riverpodиflutter_riverpodневзаимозаменяемы. - Показ состояний загрузки и ошибок через AsyncValue, когда notifier начнёт работать с вводом-выводом.
Источники
- Что нового в Riverpod 3.0 про единый
Ref,ref.mounted,ProviderContainer.test()и фильтрацию уведомлений по==. - Справочник по провайдерам Riverpod про контракт
Notifierиbuild(). - Автоматическое освобождение в Riverpod про
isAutoDisposeиref.keepAlive(). - Переход с 2.0 на 3.0 про удаление интерфейсов
AutoDispose. - flutter_riverpod на pub.dev и riverpod_generator на pub.dev про версии 3.3.2 и 4.0.4.
- Заметки о выпусках Flutter про базовую версию 3.44 стабильную.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.