Flutter: управление состоянием с Riverpod StateNotifier

Сегодня разбираем материал исходного автора о теме «Как я управляю сложным состоянием во Flutter с помощью Riverpod StateNotifier». Практический разбор с шагами и примерами, который можно быстро применить в своей работе.


Я разрабатываю административную панель на Flutter, которая обрабатывает push-уведомления, фильтрацию пользователей, оценку аудитории и данные Firestore в реальном времени. Изначально использовал setState и Provider, но это привело к путанице, так как несколько экранов нуждались в совместном использовании и обновлении одного и того же состояния.

Переход на Riverpod с StateNotifier устранил эту путаницу. Вот как я организую это в продакшене, а не только в примерах приложений со списками задач.

Почему StateNotifier, а не другие варианты?

Я попробовал несколько подходов, прежде чем остановился на StateNotifier:

  • setState — работает для простых экранов, но превращается в кошмар, когда фильтры на одном экране влияют на данные на другом
  • Provider (vanilla) — лучше, но ChangeNotifier становится запутанным при работе с несколькими полями. В итоге вызываешь notifyListeners() повсюду и теряешь контроль над тем, что изменилось
  • Bloc — честно говоря, для моего случая это было излишеством. Слишком много шаблонного кода для того, что мне было нужно
  • Riverpod + StateNotifier — иммутабельные обновления состояния, отсутствие зависимости от BuildContext, простота тестирования. Это мне подошло

Настройка реального примера

Я создал компоновщик уведомлений с дебаунсированной оценкой аудитории. Когда администратор выбирает фильтры (пол, город, уровень и т. д.), приложение ждет 500 мс после прекращения выбора и затем вызывает Cloud Function для оценки подходящего количества пользователей.

Класс состояния

import 'package:freezed_annotation/freezed_annotation.dart';

@freezed
class NotificationState with _$NotificationState {
  const factory NotificationState({
    @Default('broadcast') String sendMode,
    @Default('') String title,
    @Default('') String body,
    String? selectedGender,
    String? selectedCity,
    @Default(false) bool isEstimating,
    int? estimatedAudience,
  }) = _NotificationState;
}

Я использую Freezed, чтобы сэкономить время на написание copyWith для многих полей. Один раз я упустил поле, и потратил 30 минут на отладку, пытаясь понять, почему мой фильтр постоянно сбрасывается.

В продакшене у меня полей больше, чем в этом фрагменте. Здесь я оставил только те, которые показывают принцип: отдельно хранятся значения формы, выбранные фильтры и служебные флаги для асинхронных операций.

StateNotifier

import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class NotificationNotifier extends StateNotifier<NotificationState> {
  NotificationNotifier(this._ref) : super(const NotificationState());

  final Ref _ref;
  Timer? _debounceTimer;

  void setGenderFilter(String? gender) {
    state = state.copyWith(selectedGender: gender);
    _debouncedEstimate();
  }

  void _debouncedEstimate() {
    _debounceTimer?.cancel();
    _debounceTimer = Timer(const Duration(milliseconds: 500), _estimateAudience);
  }

  Future<void> _estimateAudience() async {
    state = state.copyWith(isEstimating: true);
    final result = await _ref.read(cloudFunctionsProvider).call(
      'estimateAudience',
      parameters: {'gender': state.selectedGender},
    );
    if (mounted) {
      state = state.copyWith(
        isEstimating: false,
        estimatedAudience: result.data['count'] as int,
      );
    }
  }
}

Полная версия у меня длиннее: там ещё методы для текста уведомления, других фильтров, отправки сообщения и обработки ошибок. Но даже в сокращённом виде видно главное: логика ввода, debounce и сетевой вызов сосредоточены в одном месте, а не размазаны по виджетам.

Использование в UI

Приятная особенность Riverpod заключается в возможности отслеживать определенные поля. Виджет перестраивается только тогда, когда изменяется нужное поле.

final isEstimating = ref.watch(
  notificationProvider.select((s) => s.isEstimating),
);
final count = ref.watch(
  notificationProvider.select((s) => s.estimatedAudience),
);

if (isEstimating) return const CircularProgressIndicator();
if (count != null) return Text('Estimated audience: $count users');
return const Text('Select filters to see audience estimate');
}

Использование .select() здесь критично — без него виджет перестраивался бы при изменении любого поля состояния (например, ввод текста в заголовок). С помощью .select() он реагирует только на изменения isEstimating или estimatedAudience.

Выпадающие списки фильтров

final selected = ref.watch(
  notificationProvider.select((s) => s.selectedGender),
);

return DropdownButtonFormField<String>(
  value: selected,
  items: const [
    DropdownMenuItem(value: 'male', child: Text('Male')),
    DropdownMenuItem(value: 'female', child: Text('Female')),
  ],
  onChanged: (value) {
    ref.read(notificationProvider.notifier).setGenderFilter(value);
  },
);

В реальном экране у меня таких полей несколько: город, уровень пользователя, период активности. Смысл везде одинаковый: виджет только читает кусок состояния и вызывает один метод нотифайера, не зная ничего о debounce или сетевом вызове.

Что меня подловило

На практике я столкнулся с несколькими нетривиальными проблемами, которые не описаны в официальной документации.

Проверка mounted перед обновлением состояния

Если ваша Cloud Function выполняется 2–3 секунды, а пользователь уходит с экрана, вызов state = state.copyWith(...) на уничтоженном нотифайере (notifier) приводит к краху приложения. Всегда проверяйте mounted перед обновлением состояния после асинхронных операций.

Таймер дебаунса (debounce timer) не отменяется

Забыв отменить таймер в dispose(), вы получаете странные баги — например, оценка запускается уже после того, как вы покинули экран. Мне потребовалось некоторое время, чтобы понять, почему в консоли появляются случайные ошибки.

DropdownButtonFormField: value в сравнении с initialValue

Это особенность Flutter, а не Riverpod, но она меня поймала. Если вы используете свойство value, Flutter управляет отображением. Если переключиться на initialValue, оно устанавливается только один раз при построении. Когда ваше состояние обновляет выбранный фильтр, вам нужен value, чтобы выпадающий список отражал текущее состояние. Я продолжал использовать initialValue и недоумевал, почему мои выпадающие списки не обновляются.

Не помещайте тяжёлую логику в build()

Изначально у меня логика дебаунса была внутри виджета. Каждая перестройка создавала новый Timer. Перенос её в StateNotifier исправил проблему. Держите виджеты «тупыми» — они должны только читать состояние и вызывать методы нотифайера.

Когда НЕ использовать StateNotifier

Для простых экранов с 1–2 полями, которые не разделяют состояние ни с чем другим, — просто используйте хук useState (с hooks_riverpod) или даже обычный setState. Не всё требует этого паттерна.

Я использую StateNotifier, когда:

  • состояние имеет 5+ полей
  • нескольким виджетам нужно читать и записывать одно и то же состояние
  • есть асинхронная логика (вызовы API, дебаунсинг)
  • я хочу тестировать логику без виджета

Для простого переключателя или текстового поля, которое живёт в одном виджете, setState вполне подойдёт. Не усложняйте без необходимости.

Я держу для себя простой ориентир: если экран можно объяснить в двух фразах и там нет общего состояния между несколькими зонами интерфейса, StateNotifier почти наверняка будет избыточным. Как только появляется связанная форма, асинхронные проверки и зависимые поля, цена этой абстракции быстро окупается.

Подведение итогов

Riverpod + StateNotifier обеспечивает чёткое разделение между UI и бизнес-логикой. Состояние иммутабельно — никаких случайных мутаций. Логика тестируема без зависимости от виджета. Перестройки эффективны благодаря .select().

Если вы создаёте что-то сложнее одноэкранного приложения на Flutter, попробуйте этот паттерн. Поначалу он может показаться лишним шаблонным кодом, но в тот момент, когда нужно отладить проблему с состоянием на нескольких экранах, вы будете рады, что всё находится в одном предсказуемом месте.

Если хотите внедрять это без резкого переписывания проекта, начните с одного сложного сценария: фильтры, форма редактирования или мастер из нескольких шагов. На таких экранах сразу видно, насколько проще становится поддержка, когда состояние описано явно и не живёт кусками в дереве виджетов.

Ответы на эти вопросы могут быть для вас полезными

Чем StateNotifier отличается от ChangeNotifier в Provider?

StateNotifier хранит иммутабельное состояние и обновляет его через copyWith, тогда как ChangeNotifier мутирует поля напрямую и требует ручного вызова notifyListeners(). При большом числе полей ChangeNotifier быстро теряет предсказуемость: непонятно, какое именно поле изменилось и когда.

Зачем использовать Freezed вместо обычного класса с copyWith?

Freezed автоматически генерирует copyWith, ==, hashCode и toString. При 10+ полях ручная реализация copyWith — источник трудноуловимых багов: достаточно забыть одно поле, и состояние будет сбрасываться без видимой причины.

Как правильно тестировать StateNotifier без виджетов?

Создайте ProviderContainer в тесте, получите нотифайер через container.read(notificationProvider.notifier) и вызывайте его методы напрямую. Состояние читается через container.read(notificationProvider). Зависимость от BuildContext отсутствует, поэтому тест не требует WidgetTester.

Когда .select() действительно необходим?

Всегда, когда виджет использует только часть полей состояния. Без .select() любое изменение в состоянии — даже не связанное с виджетом — вызывает его перестройку. В формах с множеством полей это приводит к заметным просадкам производительности.

Можно ли использовать несколько StateNotifier на одном экране?

Да, и это нормальная практика. Разбивайте состояние по зонам ответственности: один нотифайер для фильтров, другой для данных списка, третий для состояния отправки. Riverpod позволяет читать один провайдер из другого через ref.read() внутри нотифайера.

Оцените статью
0 0 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
0
Оставьте комментарий! Напишите, что думаете по поводу статьи.x