web24.team

Управление состоянием в Flutter: setState vs Bloc vs Riverpod vs Signals

Сравниваем setState, Bloc/Cubit с обвесом из freezed и hydrated_bloc, Riverpod и Signals на одних задачах — сеть, корзина, JWT, ошибки, кэш — и решаем, что выбрать в 2026 году.

Что использовать для стейт-менеджмента, универсального ответа как не было, так и нет — есть только разный баланс между боилерплейтом, контролем и производительностью. Ниже — не очередной обзор «фичи пакета X», а прямое сравнение четырёх подходов на одних и тех же шести задачах реального e-commerce-приложения: получение списка товаров по сети, визуальное состояние виджета, JWT-авторизация, обработка ошибок, корзина и кэширование между запусками. А если интересуют более свежие новости экосистемы — вот обзор Flutter 3.47 и всего, что случилось вокруг Flutter за лето 2026.

Встроенные механизмы Flutter: что можно использовать без единого стороннего пакета

Прежде чем добавить в проект Bloc или Riverpod, стоит помнить: в Flutter уже есть три работающих механизма реактивности, и часть проектов вообще не нуждается в стороннем пакете.

ValueNotifier + ValueListenableBuilder — самый дешёвый вариант для локального визуального состояния виджета: развернуть карточку, подсветить кнопку, показать счётчик.

final isExpanded = ValueNotifier<bool>(false);

ValueListenableBuilder<bool>(
  valueListenable: isExpanded,
  builder: (context, expanded, _) => expanded
      ? const Text('Развёрнуто: полное описание товара')
      : const Text('Свёрнуто'),
);

StreamController + StreamBuilder — можно использовать, когда источник данных по природе поток: сокет с ценами, GPS-координаты курьера, таймер.

final priceStream = StreamController<double>.broadcast();

StreamBuilder<double>(
  stream: priceStream.stream,
  builder: (context, snapshot) =>
      Text(snapshot.hasData ? '₽${snapshot.data}' : 'ожидаем цену'),
);

ChangeNotifier + InheritedWidget — механизм, на котором целиком построен package:provider, а не альтернатива ему.

class CartModel extends ChangeNotifier {
  final items = <String, int>{};
  void add(String sku) {
    items[sku] = (items[sku] ?? 0) + 1;
    notifyListeners();
  }
}

Listenable — общий интерфейс над всеми тремя (у ValueNotifier, ChangeNotifier и AnimationController один и тот же контракт addListener/removeListener), поэтому AnimatedBuilder и ListenableBuilder одинаково работают с любым из них. Именно эти три примитива в 2026 году — не «устаревший способ», а нижний уровень, из которого сделаны почти все пакеты ниже. Ошибки возникают, когда состояние становится глобальным: JWT-сессия или корзина нужны десяткам экранов одновременно, и протаскивать ChangeNotifier через конструкторы вручную быстро превращается в боль — вот тут мы и подходим к Bloc, Riverpod и Signals.

Общий код для примеров

Все четыре подхода ниже решают одни и те же задачи на одном домене — простой каталог товаров с публичного API fakestoreapi.com, корзиной и авторизацией. Модель и репозиторий одни на все примеры:

class Product {
  Product({required this.id, required this.title, required this.price, required this.image});
  final int id;
  final String title;
  final double price;
  final String image;
  factory Product.fromJson(Map<String, dynamic> j) => Product(
        id: j['id'],
        title: j['title'],
        price: (j['price'] as num).toDouble(),
        image: j['image'],
      );
}

class ProductRepository {
  Future<List<Product>> fetchAll() async {
    final res = await http.get(Uri.parse('https://fakestoreapi.com/products'));
    return (jsonDecode(res.body) as List).map(Product.fromJson).toList();
  }
}

Код ниже можно вставить в новый Flutter-проект на dartpad.dev — DartPad сам подтягивает пакеты по import. Оговорка: codegen-пакеты в DartPad не работают — там нет build_runner, поэтому freezed-классы и генератор Riverpod (@riverpod) собрать нельзя. Все примеры ниже написаны без кодогена специально, чтобы работали в браузерной песочнице; freezed и riverpod_generator обсуждаем в тексте отдельно как продакшен-надстройку — их стоит пробовать в реальном проекте (flutter create), не в DartPad. Это, кстати, само по себе неплохая иллюстрация разницы в «тяжести»: setState и Signals пробуются за 30 секунд в браузере, у полного стека Bloc с freezed порог входа выше уже на этапе «просто посмотреть код».

Получение данных по сети и обработка ошибок в Flutter

setState — обычный StatefulWidget, три флага состояния вручную:

class ProductList extends StatefulWidget {
  const ProductList({super.key});
  @override
  State<ProductList> createState() => _ProductListState();
}

class _ProductListState extends State<ProductList> {
  final _repo = ProductRepository();
  List<Product>? _products;
  Object? _error;

  @override
  void initState() {
    super.initState();
    _load();
  }

  Future<void> _load() async {
    setState(() {
      _error = null;
      _products = null;
    });
    try {
      final items = await _repo.fetchAll();
      setState(() => _products = items);
    } catch (e) {
      setState(() => _error = e);
    }
  }

  @override
  Widget build(BuildContext context) {
    if (_error != null) return Text('Ошибка: $_error');
    if (_products == null) return const CircularProgressIndicator();
    return ListView(children: [for (final p in _products!) Text(p.title)]);
  }
}

Работает без единой зависимости, но loading/data/error — три независимых поля, которые ничто не мешает рассинхронизировать (например, забыть сбросить _error перед повторной загрузкой).

Bloc/Cubit явно моделирует три состояния как отдельные типы через sealed class — уже без freezed компилятор не даст забыть ветку в switch:

sealed class ProductState {}
class ProductLoading extends ProductState {}
class ProductLoaded extends ProductState {
  ProductLoaded(this.items);
  final List<Product> items;
}
class ProductError extends ProductState {
  ProductError(this.message);
  final String message;
}

class ProductCubit extends Cubit<ProductState> {
  ProductCubit(this._repo) : super(ProductLoading()) { load(); }
  final ProductRepository _repo;

  Future<void> load() async {
    emit(ProductLoading());
    try {
      emit(ProductLoaded(await _repo.fetchAll()));
    } catch (e) {
      emit(ProductError(e.toString()));
    }
  }
}

// в дереве виджетов
BlocProvider(
  create: (_) => ProductCubit(getIt<ProductRepository>()),
  child: BlocBuilder<ProductCubit, ProductState>(
    builder: (context, state) => switch (state) {
      ProductLoading() => const CircularProgressIndicator(),
      ProductError(:final message) => Text('Ошибка: $message'),
      ProductLoaded(:final items) => ListView(children: [for (final p in items) Text(p.title)]),
    },
  ),
);

getIt<ProductRepository>() — это get_it: репозиторий регистрируется один раз при старте и достаётся откуда угодно без протаскивания через конструкторы. В продакшене вместо ручных sealed class обычно берут freezed — он экономит ==/hashCode/copyWith, но требует build_runner.

Третий пакет из «полного обвеса» — rxdart — раскрывается не в самой загрузке списка, а там, где нужно свести данные из двух независимых Bloc в одно UI-состояние, не связывая их напрямую. Каталог и корзина — типичный случай: CartCubit знает только id товаров, а всю карточку — фото, название, цену — берёт ProductRepository, который уже когда-то её загрузил. Для этого репозиторий дополнительно кеширует загруженный список в BehaviorSubject — потоке, который отдаёт новому подписчику последнее сохранённое значение сразу, а не ждёт следующего fetchAll():

class ProductRepository {
  final _cache = BehaviorSubject<List<Product>>.seeded([]);
  Stream<List<Product>> get products$ => _cache.stream;

  void fetchAll() async {
    final res = await http.get(Uri.parse('https://fakestoreapi.com/products'));
    final items = (jsonDecode(res.body) as List).map(Product.fromJson).toList();
    _cache.add(items);
  }
}

Мутации корзины по-прежнему работают только с int, но сведение с каталогом через Rx.combineLatest2 теперь происходит внутри самого CartCubit, а не в виджете — наружу Cubit отдаёт уже готовый List<Product>:

class CartCubit extends Cubit<List<Product>> {
  CartCubit(this._repo) : super(const []) {
    _subscription = Rx.combineLatest2<Set<int>, List<Product>, List<Product>>(
      _ids.stream,
      _repo.products$,
      (ids, products) => products.where((p) => ids.contains(p.id)).toList(),
    ).listen(emit);
  }

  final ProductRepository _repo;
  final _ids = BehaviorSubject<Set<int>>.seeded({});
  late final StreamSubscription<void> _subscription;

  void add(int productId) => _ids.add({..._ids.value, productId});
  void remove(int productId) => _ids.add({..._ids.value}..remove(productId));

  @override
  Future<void> close() async {
    await _subscription.cancel();
    await _ids.close();
    return super.close();
  }
}

_ids — тоже BehaviorSubject, а не обычный StreamController: combineLatest2 эмитит только тогда, когда у него есть хотя бы одно значение с обеих сторон, и seed-значение {} гарантирует, что комбинированный поток соберётся сразу, как только каталог загрузится, не дожидаясь первого товара в корзине. Экран корзины теперь вообще не знает ни про ProductRepository, ни про rxdart — обычный BlocBuilder, как в любом другом Bloc-примере:

class CartView extends StatelessWidget {
  const CartView({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<CartCubit, List<Product>>(
      builder: (context, items) => ListView(children: [
        for (final p in items)
          ListTile(
            leading: Image.network(p.image),
            title: Text(p.title),
            subtitle: Text('₽${p.price}'),
          ),
      ]),
    );
  }
}

Цена переноса — CartCubit больше не полностью независим от Product, ему нужен ProductRepository в конструкторе. Но для bloc_test это не проблема: подсовывается фейковый репозиторий с уже засеянным products$, реальная сеть по-прежнему не нужна.

Riverpod сводит три состояния к одному AsyncValue, который FutureProvider собирает сам:

final productRepositoryProvider = Provider((ref) => ProductRepository());

final productListProvider = FutureProvider<List<Product>>((ref) {
  return ref.watch(productRepositoryProvider).fetchAll();
});

// в виджете
Consumer(
  builder: (context, ref, _) {
    final products = ref.watch(productListProvider);
    return products.when(
      loading: () => const CircularProgressIndicator(),
      error: (e, _) => Text('Ошибка: $e'),
      data: (items) => ListView(children: [for (final p in items) Text(p.title)]),
    );
  },
);

Ни отдельного класса состояния, ни DI-контейнера: граф провайдеров сам работает как DI, productRepositoryProvider подставляется куда угодно через ref.watch. В продакшене это чаще пишут с аннотацией @riverpod и кодогеном — код выходит короче ещё на пару строк, но тоже требует build_runner.

Signals обходится без отдельного класса состояния вообще — используем встроенный в Flutter AsyncSnapshot как тип значения сигнала:

final productsSignal = signal<AsyncSnapshot<List<Product>>>(
  const AsyncSnapshot.waiting(),
);

Future<void> loadProducts(ProductRepository repo) async {
  productsSignal.value = const AsyncSnapshot.waiting();
  try {
    productsSignal.value = AsyncSnapshot.withData(ConnectionState.done, await repo.fetchAll());
  } catch (e) {
    productsSignal.value = AsyncSnapshot.withError(ConnectionState.done, e);
  }
}

// в виджете
Watch((context) {
  final snap = productsSignal.value;
  if (snap.hasError) return Text('Ошибка: ${snap.error}');
  if (!snap.hasData) return const CircularProgressIndicator();
  return ListView(children: [for (final p in snap.data!) Text(p.title)]);
});

signal<T>() — это просто «умная переменная»: Watch подписывается на неё автоматически, без BlocBuilder, Consumer или context.watch.

Корзина, визуальное состояние виджета и кэш между запусками в Flutter

Корзина хорошо совмещает сразу три задачи: список товаров с количеством — это состояние виджета (бейдж на иконке), а «пережить перезапуск приложения» — это кэширование.

setState — тот же паттерн, что и раньше, но персистентность на диск нужно писать руками в каждом месте, где меняется корзина:

class CartBadge extends StatefulWidget {
  const CartBadge({super.key});
  @override
  State<CartBadge> createState() => _CartBadgeState();
}

class _CartBadgeState extends State<CartBadge> {
  final Map<String, int> _items = {};

  void _add(String sku) => setState(() => _items[sku] = (_items[sku] ?? 0) + 1);
  int get _count => _items.values.fold(0, (a, b) => a + b);

  @override
  Widget build(BuildContext context) => Badge(
        label: Text('$_count'),
        child: IconButton(icon: const Icon(Icons.shopping_cart), onPressed: () => _add('sku-1')),
      );
}

Bloc — здесь стоит увидеть разницу до и после hydrated_bloc. Без него сохранение на диск — ручной код в каждом методе:

Плохо (без hydrated_bloc, сохранение руками в каждом мутирующем методе):

class CartCubit extends Cubit<Map<String, int>> {
  CartCubit() : super({}) { _restore(); }

  Future<void> _restore() async {
    final raw = (await SharedPreferences.getInstance()).getString('cart');
    if (raw != null) emit(Map<String, int>.from(jsonDecode(raw)));
  }

  Future<void> add(String sku) async {
    final items = {...state, sku: (state[sku] ?? 0) + 1};
    emit(items);
    (await SharedPreferences.getInstance()).setString('cart', jsonEncode(items));
  }
}

Хорошо (HydratedCubit сам сериализует state при каждом emit и восстанавливает при холодном старте):

class CartCubit extends HydratedCubit<Map<String, int>> {
  CartCubit() : super({});

  void add(String sku) => emit({...state, sku: (state[sku] ?? 0) + 1});

  @override
  Map<String, int> fromJson(Map<String, dynamic> json) => Map<String, int>.from(json);
  @override
  Map<String, dynamic>? toJson(Map<String, int> state) => state;
}

Метод add больше вообще не знает о существовании диска — вся персистентность ушла в fromJson/toJson. Цена — инициализация хранилища один раз в main() (HydratedBloc.storage = await HydratedStorage.build(...)) и то, что это решение целиком внутри экосистемы Bloc: у Riverpod и Signals готового аналога нет.

Riverpod — персистентность пишется вручную, как в «плохом» варианте Bloc выше, но без отдельного класса состояния:

class Cart extends Notifier<Map<String, int>> {
  @override
  Map<String, int> build() {
    _restore();
    return {};
  }

  Future<void> _restore() async {
    final raw = (await SharedPreferences.getInstance()).getString('cart');
    if (raw != null) state = Map<String, int>.from(jsonDecode(raw));
  }

  Future<void> add(String sku) async {
    state = {...state, sku: (state[sku] ?? 0) + 1};
    (await SharedPreferences.getInstance()).setString('cart', jsonEncode(state));
  }
}

final cartProvider = NotifierProvider<Cart, Map<String, int>>(Cart.new);

Signals — минимум усилий: сохранение живёт отдельно от мутации, как побочный эффект.

final cart = signal<Map<String, int>>({});

void addToCart(String sku) {
  cart.value = {...cart.value, sku: (cart.value[sku] ?? 0) + 1};
}

Future<void> restoreCart() async {
  final raw = (await SharedPreferences.getInstance()).getString('cart');
  if (raw != null) cart.value = Map<String, int>.from(jsonDecode(raw));
}

// автосохранение при любом изменении — signals сам отследит зависимость
effect(() {
  final snapshot = cart.value;
  SharedPreferences.getInstance().then((p) => p.setString('cart', jsonEncode(snapshot)));
});

// бейдж на иконке — перерисуется только сам, без обёртки над всем экраном
Watch((context) => Badge(
      label: Text('${cart.value.values.fold(0, (a, b) => a + b)}'),
      child: const Icon(Icons.shopping_cart),
    ));

effect() перезапускается сам при любом изменении cart.value — не нужно ни отдельного метода add, который «знает» про сохранение, ни BlocListener, ни ref.listen.

JWT-авторизация и сетевые ошибки в Flutter

Авторизация — тот случай, где сам HTTP-слой не зависит от выбора стейт-менеджера: токен добавляется в заголовки через интерцептор Dio или http, а стейт-менеджер только показывает результат.

class AuthInterceptor extends Interceptor {
  AuthInterceptor(this._storage);
  final TokenStorage _storage;

  @override
  Future<void> onRequest(RequestOptions options, handler) async {
    final token = await _storage.read();
    if (token != null) options.headers['Authorization'] = 'Bearer $token';
    handler.next(options);
  }

  @override
  Future<void> onError(DioException err, handler) async {
    if (err.response?.statusCode == 401) await _storage.clear();
    handler.next(err);
  }
}

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

Подход Как сессия становится видна всему приложению
setState Никак напрямую — нужен ChangeNotifier/InheritedWidget поверх, то есть фактически отказ от чистого setState
Bloc AuthCubit эмитит AuthState.loggedOut на 401, BlocListener в корне слушает и делает редирект
Riverpod authProvider инвалидируется (ref.invalidate), все зависящие провайдеры перезапрашиваются сами
Signals authSignal.value = null, любой Watch, читающий сигнал, обновляется сам

Сколько весит каждый подход

Критерий setState Bloc/Cubit (полный стек) Riverpod Signals
Пакетов в стеке 0 5: flutter_bloc, get_it, freezed, hydrated_bloc, rxdart 1: flutter_riverpod (+ riverpod_generator опционально) 2: signals, signals_flutter
Нужен build_runner нет да, для freezed нет, если без кодогена нет, никогда
Отдельный DI-контейнер не нужен get_it отдельным пакетом не нужен — граф провайдеров сам DI не нужен
Персистентность из коробки нет да, HydratedCubit нет, вручную нет, вручную
Своя ошибка компилятора при забытой ветке состояния нет да (sealed class/freezed) частично (AsyncValue.when заставляет обработать все случаи) нет, руками через AsyncSnapshot

Производительность: что вызывает ребилд

Цифры бенчмарков для стейт-менеджеров почти всегда меряют синтетику и плохо переносятся на реальный экран — правильнее сравнивать сам механизм: что именно вызывает перерисовку и насколько точно можно её ограничить.

Подход Что перерисовывается по умолчанию Как сузить
setState Весь build() виджета, где вызван setState Дробить на мелкие StatefulWidget вручную
ChangeNotifier + Provider Все context.watch()-подписчики целиком объекта context.select() на конкретное поле
Bloc/Cubit + BlocBuilder Весь builder, если не задано иное buildWhen или BlocSelector на конкретное поле
Riverpod Только виджеты, читающие именно этот provider .select() для реакции на одно поле объекта
Signals Только Watch, реально читающий изменившийся сигнал Ничего не нужно — трекинг зависимостей автоматический

Практический вывод: у Riverpod и Signals точечная перерисовка — поведение по умолчанию, у Provider и Bloc — опция, которую надо явно включить (select, buildWhen). На экране с десятком независимых полей (карточка товара с ценой, рейтингом, наличием, состоянием избранного) это ощутимая разница в количестве лишних build() — но проявляется она на большом, часто обновляемом экране, а не на форме логина, где рендерить нечего в любом случае.

Удобство и порог входа

  • setState — низкий порог, любой Flutter-разработчик пишет с первого дня. Ломается не по сложности кода, а по масштабу: как только состояние нужно двум несвязанным экранам, приходится либо использовать колбэки через дерево виджетов, либо всё равно подключать один из трёх пакетов ниже.
  • Bloc — самый высокий порог входа: нужно понять events/states, BlocProvider/BlocBuilder/BlocListener, синтаксис freezed и то, зачем вообще нужен get_it. Отдача — предсказуемая архитектура, которую легко тестировать (bloc_test проверяет последовательность состояний один в один) и объяснить новому разработчику по диаграмме событий, не читая код.
  • Riverpod — средний порог, документация и миграция с Provider хорошо проработаны, а AsyncValue.when физически не даёт забыть обработать ошибку. Ошибки конфигурации (в отличие от Provider) ловятся на этапе компиляции, а не крашем в рантайме.
  • Signals — низкий порог: код выглядит как обычные переменные с .value. Меньше архитектурных рамок — это и плюс для маленькой команды, и риск для большой: без Bloc-style дисциплины легче незаметно раскидать мутации состояния по всему коду. Экосистема моложе, туториалов и сеньоров, которые уже прошли через эти грабли, меньше.

Что выбрать в 2026 году

  • Прототип, MVP, соло-разработчик — setState плюс ValueNotifier/ChangeNotifier для того немногого, что действительно расшарено между экранами. Использовать Bloc или Riverpod ради формы логина — классический пример архитектуры под воображаемый масштаб, которого ещё нет.
  • Средний e-commerce или бизнес-апп, команда 2–5 человек — Riverpod: лучший баланс боилерплейта и контроля, официальный преемник Provider, точечные ребилды и обработка ошибок из коробки через AsyncValue.
  • Крупная команда, долгоживущий продукт с высокими требованиями к тестам и ревью (финтех, медицина, большой ритейл) — Bloc: цена в виде events/states и кодогена окупается предсказуемостью, готовым журналом событий для аналитики и зрелым bloc_test.
  • Экраны с частыми точечными обновлениями — живые цены, дашборды, чаты — и команда, готовая к менее устоявшейся экосистеме: Signals за счёт автоматической точечной реактивности без ручных селекторов.

Коротко

Какой пакет для управления состоянием выбрать в Flutter в 2026 году?

Однозначного лидера нет: для прототипа хватает setState и ValueNotifier, для среднего проекта лучший баланс даёт Riverpod, для крупной команды с упором на тестируемость — Bloc, а для UI с частыми точечными обновлениями — Signals.

В чём разница между Bloc и Cubit?

Cubit — упрощённый Bloc, где методы напрямую вызывают emit(). Bloc добавляет явные события и обработчики on<Event> — больше кода, зато готовый журнал всех событий приложения.

Нужен ли Provider, если есть Riverpod?

В новых проектах — нет. Riverpod от того же автора решает проблемы Provider: не завязан на BuildContext, ошибки ловятся при компиляции, провайдеры читаются вне дерева виджетов.

Что такое Signals в Flutter и стоит ли их использовать?

Пакет с точечной реактивностью по образцу SolidJS и Preact Signals: виджет перерисовывается только при изменении конкретного значения, на которое подписан. Стоит рассматривать для реактивных интерфейсов, но экосистема моложе, чем у Bloc и Riverpod.

Как кэшировать и сохранять состояние Flutter-приложения между запусками?

У Bloc есть готовое решение — HydratedCubit/HydratedBloc сериализует state на диск при каждом изменении и восстанавливает при старте. У Riverpod и Signals персистентность пишется вручную поверх SharedPreferences, Hive или Isar.

А как же GetX — почему его нет в сравнении?

Сознательно не включён, и в продакшен-проект его лучше вообще не брать: у GetX другая философия — глобальный сервис-локатор, неявная реактивность через .obs и собственные роутинг с DI вместо стандартных инструментов Flutter. Это ускоряет старт, но те же неявные зависимости через Get.find() плохо тестируются на большом проекте, поэтому для продакшена в 2026 году сообщество увереннее рекомендует Riverpod или Bloc.

Более серьёзная проблема вылезает не сразу, а при первой попытке что-то заменить: в GetX стейт-менеджмент, роутинг, DI-контейнер и локализация — это не независимые библиотеки, а один пакет get, где всё держится друг за друга. В остальных подходах из этого сравнения замена одной части не трогает остальные — например, роутер можно сменить с go_router на любой другой независимо от того, Bloc у вас или Riverpod. С GetX так не выйдет: если через год понадобится сменить только стейт-менеджмент на Riverpod, или только роутинг на go_router, или только локализацию на intl — тянуть за одну ниточку придётся весь пакет разом, потому что Get.find(), GetX<T>, роуты Get.to() и переводы .tr завязаны на один и тот же сервис-локатор. Чем больше экран написан на GetX, тем дороже переезд.

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