Что использовать для стейт-менеджмента, универсального ответа как не было, так и нет — есть только разный баланс между боилерплейтом, контролем и производительностью. Ниже — не очередной обзор «фичи пакета 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, тем дороже переезд.
Если нужно спроектировать архитектуру мобильного приложения под конкретную нагрузку и команду — пишите нам через форму на сайте, поможем выбрать подход и не переплатить за обвес, который вам не нужен.