Потому что состояние, которое читают несколько виджетов или которое переживает экран, не помещается в один State: значения и колбэки приходится протаскивать через каждый конструктор, логику нельзя тестировать вне виджетов, а перестройки получаются слишком широкими.
Prop drilling: значение, нужное на пять уровней ниже, едет через виджеты, которым оно не нужно, и при изменении данных меняются все сигнатуры
Тестируемость: логику внутри классов State можно проверить только через виджеты; отдельное хранилище или notifier написан на обычном Dart
Время жизни: State умирает вместе с виджетом, поэтому данным, которые должны пережить уход с экрана (корзина, авторизованный пользователь), нужен владелец над экранами
Область перестройки: setState высоко в дереве перестраивает всё поддерево; хранилище позволяет перестраиваться только читателям одного значения
Не всегда: чисто локальное состояние UI (выбранная вкладка, чекбокс) по-прежнему лучше держать в setState
FlutterJunior
Базовое управление состоянием
setState, InheritedWidget, ValueNotifier
Закрыто вопросов
0 / 23
- 0Повторить
- 8Часто спрашивают
Вопросы
Встроенные: setState, InheritedWidget, ValueNotifier/ChangeNotifier; пакеты: Provider, Riverpod, BLoC/Cubit, реже MobX, Redux, GetX. Правило: setState для локального UI, Provider или Riverpod для общего состояния, BLoC для больших команд со строгими событиями.
**setState и встроенные средства: локальное состояние UI; без зависимостей, но ничего не расшарить за пределы поддерева
Provider**: тонкая обёртка над InheritedWidget, которая создаёт, освобождает и раздаёт объекты; Riverpod того же автора убирает зависимость от BuildContext
Riverpod: провайдеры объявляются вне дерева, безопасность на этапе компиляции, встроенная асинхронность (AsyncValue) и простые подмены в тестах
BLoC/Cubit: на вход события, на выход состояния через стримы; кода больше, зато каждое изменение состояния явное и отслеживаемое, что ценят большие команды
Как выбрать: опыт команды, размер приложения, объём асинхронной работы, тестируемость и поддержка пакета; одна библиотека для общего состояния плюс setState для локального UI, а не две, спорящие за одну фичу
setState сразу выполняет ваше замыкание, затем помечает элемент этого State как «грязный» (dirty) и планирует кадр. На этом кадре Flutter снова вызывает build() и сравнивает новые виджеты с уже существующими элементами.
Что перестраивается: всё, что возвращает этот build, создаётся заново как виджеты; элементы и render-объекты переиспользуются там, где совпадают тип и ключ, а идентичный const-потомок пропускается целиком
Не мгновенно: перестройка ждёт следующего кадра, поэтому несколько вызовов в одном событии стоят один build
Вызывайте как можно ниже по дереву: в самом маленьком виджете, который владеет меняющимися данными, а не на всей странице
**Не внутри build**: build должен быть чистой функцией состояния и может выполняться каждый кадр, поэтому менять состояние в нём нельзя. setState на том же State ничего не даёт (элемент уже dirty); пометка предка или другого уже построенного виджета (колбэк в setState родителя, синхронный notifyListeners) бросает «setState() or markNeedsBuild() called during build»
**Не после dispose**: проверяйте mounted после каждого await, прежде чем его вызвать
count является локальной переменной build. setState действительно её увеличивает, но запланированная им перестройка снова вызывает build, а там объявляется новая count = 0, поэтому на экране всегда 0.
Исправление: сделать её полем State: int _count = 0; над build. Объект State переживает перестройки, а локальные переменные build и поля StatefulWidget не переживают
Мутация на месте: вы добавили элемент в тот же List, и потомок, сравнивающий oldWidget.items != widget.items, видит тот же объект и ничего не делает
Быстрая проверка: поставьте debugPrint в build: если вывода нет, ничего не помечено как dirty; если выводится старое значение, неверны сами данные
Поднять состояние означает перенести значение в ближайшего общего родителя виджетов, которые его читают или меняют, а затем передавать значение вниз параметром, а изменения отправлять наверх через колбэки.
Два соседних виджета не могут общаться напрямую; встретиться они могут только в общем родителе. Положите значение туда, и оба увидят одну и ту же правду.
Когда: как только второму виджету нужно читать или менять значение, которым владеет один виджет
Один источник правды: нет зеркальной копии, которая разойдётся с оригиналом (классический баг _local = widget.value)
Насколько высоко: до ближайшего предка, которому это нужно, не выше
Когда остановиться: если вы протаскиваете значение через четыре слоя конструкторов, переходите на решение поверх InheritedWidget (Provider, Riverpod)
setState сразу выполняет замыкание и затем помечает элемент как dirty. async-замыкание возвращает Future, который фреймворк выбрасывает, поэтому поля присваиваются намного позже той перестройки, ради которой всё затевалось.
Что происходит: в debug: «setState() callback argument returned a Future»; в release проверки нет: Future игнорируется, поля присваиваются уже после перестройки, а ошибка внутри него становится необработанной асинхронной ошибкой
Почему молчит компилятор: () async {} имеет тип Future<void> Function(), а его можно передать туда, где ожидается void Function()
Рабочая форма: await снаружи, проверка mounted, затем setState с уже готовым результатом
В замыкании делайте только присваивания: никакого I/O, навигации и ничего, что может бросить исключение
Всё, что State создал и что живёт дольше одного build: контроллеры, подписки, таймеры и слушатели продолжают работать или держат нативные ресурсы, а те, у кого есть колбэки, ещё и не дают собрать State.
Чек-лист: созданные вами контроллеры (TextEditingController, ScrollController, AnimationController, FocusNode), подписки на стримы, таймеры, добавленные слушатели, контроллеры видео и камеры
Чем грозит забывчивость: колбэк держит this, поэтому State и всё его поддерево никогда не собираются сборщиком мусора; таймер, сработавший позже, попадает в мёртвый State («setState() called after dispose()»), а утёкший AnimationController продолжает планировать кадры
Два правила: вызывайте super.dispose() последним и никогда не освобождайте объект, который вам передал родитель: он принадлежит родителю
Сначала выведется after: 3, затем один раз build: 3. Каждый setState сразу выполняет замыкание, но все три лишь помечают один и тот же элемент как dirty, и перестройка происходит один раз, на следующем кадре.
Замыкание синхронное: на строке после вызовов _count уже равен 3
Перестройка не синхронная: markNeedsBuild на уже dirty-элементе сразу выходит, поэтому N вызовов в одном событии стоят один build
Объединять вручную не нужно: но один setState вокруг всех связанных изменений читается лучше
Что ещё не готово: новая разметка. Размеры, границы скролла и значения RenderBox пока описывают старый кадр; читайте их в WidgetsBinding.instance.addPostFrameCallback
Один bool _saving в State: выставьте его до await, сбросьте в finally, если экран остаётся открытым, и передавайте onPressed: null, пока он выставлен; тогда кнопка заметно неактивна, пока идёт работа.
Почему хватает одного флага: обработчик выполняется в одном потоке, поэтому if (_saving) return; и присваивание происходят в одном синхронном отрезке; второе нажатие не может вклиниться между ними
**Сбрасывайте в finally, кроме успеха с закрытием экрана**: если сбрасывать только при успехе, одно исключение навсегда оставит кнопку мёртвой; а при успехе, закрывающем экран, флаг оставьте выставленным, иначе кнопка оживёт на время анимации pop; setState защищайте проверкой mounted
Это не debounce: debounce откладывает первый вызов и никак не мешает второму, пока первый ещё выполняется
Только UX-исправление: если заказ ни в коем случае нельзя создать дважды, серверу нужен ключ идемпотентности: пересозданный виджет снова начнёт с false
Это приводит к исключению: в debug «setState() called after dispose()», в release ошибка null-check. Пока вы ждёте await, пользователь может уйти, и State освобождается; mounted показывает, остался ли он в дереве, поэтому проверяйте его до обращения к состоянию или context.
Паттерн: после каждого await в методе State пишите if (!mounted) return; перед setState, context или навигацией
**Не используйте context через разрыв**: заранее возьмите то, что нужно, например final messenger = ScaffoldMessenger.of(context);, и используйте это после await
Убирайте за собой: отменяйте таймеры, подписки и незавершённую работу в dispose, чтобы ничто не вызвало колбэк в мёртвом State
Пусть помогает анализатор: линт use_build_context_synchronously ловит типичные случаи; вне State (в StatelessWidget, в колбэке) используйте context.mounted
Оба вызываются до первого build, но initState выполняется ровно один раз, а didChangeDependencies вызывается сразу после него и затем каждый раз, когда меняется inherited-виджет, от которого зависит этот State.
**initState**: создаём контроллеры, нотифаеры, подписки; запускаем разовую загрузку. Вызывать Theme.of или MediaQuery.of здесь нельзя: Flutter бросит исключение, потому что State ещё не закончил инициализацию
**didChangeDependencies**: первое место, где разрешён .of(context), и место, где реагируют на смену темы, локали или скоупа, например переподписываются на репозиторий, взятый из контекста
**didUpdateWidget(old): родственный хук: родитель перестроил этот виджет с новыми аргументами конструктора; сравнивайте их здесь
Не копируйте поля widget**: читайте widget.title прямо в build; копия, сделанная в initState, устаревает, потому что State переиспользуется (синхронизируйте в didUpdateWidget)
Частая ошибка: тяжёлая загрузка в didChangeDependencies без проверки; метод вызывается снова при любой смене зависимостей, а не только той, что вас интересует
_qty копируется из widget.quantity только в initState. Когда родитель перестраивает виджет с новым quantity, Flutter переиспользует тот же State, initState повторно не вызывается, и степпер продолжает показывать устаревшую копию.
**Почему State выживает**: у виджета на этой позиции тот же тип и ключ, поэтому элемент вызывает didUpdateWidget(oldWidget) у существующего State, а не создаёт новый; подменяется только widget
Исправление: переопределите didUpdateWidget и синхронизируйте копию, когда новое значение отличается от oldWidget.quantity; сравнение нужно, чтобы перестройка с тем же значением не затирала локальное изменение
Ещё лучше не хранить копию: если числом владеет родитель, читайте widget.quantity в build и вызывайте widget.onChanged(widget.quantity + 1): контролируемый виджет не может разойтись с источником
Не исправление: ValueKey(quantity) на степпере заставляет создать новый State, но при каждом изменении выбрасывает и всё остальное его состояние (фокус, анимации)
Как поймать: виджет-тест: отрисовать степпер с quantity: 5, затем с quantity: 1 и ожидать find.text('1')
Когда выполняется build, Flutter уже обходит дерево, и пометить в этот момент как dirty предка или уже построенный виджет значило бы оставить кадр недостроенным, поэтому фреймворк отказывается.
Что её вызывает: вызов setState родителя через колбэк из build или initState потомка, синхронный notifyListeners() из них же (оба выполняются внутри build родителя), показ снекбара или диалога либо навигация из метода жизненного цикла
Не храните то, что можно вычислить: если значение выводится из входных данных виджета, вычисляйте его в build и удалите поле
Перенесите триггер: побочным эффектам место в колбэке, который их вызвал: onPressed, слушатель, обработчик стрима
Если действие правда должно следовать за изменением: отложите его на кадр через WidgetsBinding.instance.addPostFrameCallback с проверкой mounted
Реагируйте на смену состояния слушателем: BlocListener, ref.listen или addListener из initState; build не место для реакций
Диалог живёт в отдельном маршруте: setState страницы перестраивает страницу, а содержимое диалога построено builder, который вы передали в showDialog, и в поддерево страницы не входит.
**StatefulBuilder**: минимальное исправление: оберните содержимое диалога и получите локальный setState в рамках этого билдера; для одного чекбокса или слайдера достаточно
**Полноценный StatefulWidget**: лучше, когда в диалоге несколько полей: есть initState и dispose для контроллеров, и его можно протестировать отдельно
**ValueNotifier + ValueListenableBuilder: когда значение важно и за пределами диалога
Как вернуть ответ**: Navigator.pop(context, value) внутри, await showDialog<bool>(...) снаружи, затем проверка context.mounted
ValueNotifier<T> устроен как ChangeNotifier, который хранит одно value и уведомляет слушателей, когда это значение заменяют. В паре с ValueListenableBuilder перестраивается только маленькое поддерево внутри билдера, причём без всяких пакетов.
**Отличие от ChangeNotifier**: ChangeNotifier хранит сколько угодно полей, и notifyListeners() вы вызываете сами; ValueNotifier уведомляет при присваивании и только если новое значение != старому
**Лучше setState: когда от значения зависит один «лист» (счётчик, иконка, активность кнопки), а остальной экран перестраиваться не должен
Сравнение через ==**: мутация List на месте никого не уведомит; присваивайте новый список
Освобождайте его: вы создали Listenable, значит, освободите его в dispose
Ограничение: одно значение; когда несколько полей должны меняться вместе, берите ChangeNotifier или полноценное хранилище
child строится один раз, вне билдера, и передаётся в него без изменений. Поскольку это каждый раз один и тот же экземпляр виджета, элемент переиспользует его и никогда не перестраивает это поддерево.
Весь фокус в идентичности: Element.updateChild сравнивает новый виджет с текущим; тот же экземпляр означает, что build у него не вызывается; то же правило делает const-поддеревья бесплатными
Как разделить: внутрь builder идут только виджеты, которые читают значение; всё статичное и тяжёлое идёт в child
Держите билдер низко: билдер у корня всё равно перестроит виджеты целой страницы, что бы вы ни передали в child
Тот же параметр в других местах: он есть у ListenableBuilder, AnimatedBuilder и Consumer из provider, ровно по этой причине
В TextEditingController. Он уже является ValueNotifier<TextEditingValue>, который слушает TextField, поэтому поле само перерисовывается на каждый символ, а выше ничего перестраивать не нужно.
Один источник правды: читайте controller.text при отправке; зеркальное поле String _text создаёт вторую правду, которая разойдётся с первой
Когда от текста зависит что-то ещё: активность кнопки «Отправить», счётчик символов. Оберните только этот виджет в ValueListenableBuilder на тот же контроллер
Для валидации: Form + TextFormField с validator и autovalidateMode: AutovalidateMode.onUserInteraction; каждое поле хранит свою ошибку
**Проблема не в onChanged**: это по-прежнему правильный хук для debounce поиска или передачи текста в bloc; ошибка именно в setState
InheritedWidget раздаёт данные всему, что ниже него, не протаскивая их через каждый конструктор, и перестраивает только те виджеты, которые их читают. На нём построены Theme.of, MediaQuery и Provider; updateShouldNotify решает, перестроятся ли читатели.
Потомок вызывает .of(context) (по сути dependOnInheritedWidgetOfExactType) и регистрируется как зависимый. Когда родитель перестраивает inherited-виджет с новым экземпляром, Flutter вызывает updateShouldNotify(oldWidget); если он вернул true, все зависимые помечаются как dirty.
**updateShouldNotify**: сравнивайте важные поля (oldWidget.primary != primary); true всегда тоже работает, но перестраивает зависимых при каждой перестройке родителя
Новый экземпляр, а не мутация: виджет неизменяемый; чтобы поменять данные, StatefulWidget выше перестраивает его с новыми значениями
Быстрый поиск: каждый элемент хранит карту inherited-виджетов над собой, поэтому .of(context) стоит одинаково на любой глубине
Частая ошибка: вызывать .of(context) в initState: делайте это в didChangeDependencies или в build
Provider построен на InheritedWidget. Он добавляет то, чего нет у «голого» виджета: создание и освобождение объекта, перестройку зависимых, когда ChangeNotifier уведомляет, чтение без подписки и выбор одного поля.
**Голый InheritedWidget неизменяем**: чтобы поменять значение, его перестраивают из StatefulWidget выше, а of() и updateShouldNotify пишут вручную
Жизненный цикл: ChangeNotifierProvider(create: ...) создаёт объект лениво и вызывает dispose, когда провайдер уходит из дерева; с голым виджетом и то и другое на вас
Подписка: Provider подписывается на Listenable и уведомляет зависимых; в самом SDK ту же работу делает InheritedNotifier
Режимы доступа: context.watch подписывается, context.read только получает (для колбэков), context.select перестраивает по одному полю; у простого InheritedWidget есть только «зависеть» и «получить»
Ошибки: отсутствующий провайдер бросает ProviderNotFoundException с объяснением вместо голого null
Когда хватает голого: пакет или дизайн-система, раздающие несколько неизменяемых значений, как Theme, без лишней зависимости
GetX объединяет состояние, навигацию, DI, переводы и HTTP в одном пакете, построенном на глобальных синглтонах и API без контекста. Начать с ним быстро, но он прячет жизненные циклы, привязывает всё приложение к одному пакету и усложняет тестирование и обновления.
Глобальный service locator: Get.put и Get.find глобальны; зависимости не видны в конструкторах, а тестам приходится сбрасывать реестр между кейсами
Обход дерева виджетов: Get.to и Get.snackbar без BuildContext идут в обход модели Flutter; время жизни контроллеров, привязанное к маршрутам, превращается в поведение, которое приходится отлаживать, а не код, который можно прочитать
Всё в одном даёт жёсткую привязку (lock-in): роутинг, состояние и DI переплетены, поэтому замена одной части означает переписывание остальных
Реактивная «магия»: переменные .obs и Obx скрывают, что именно перестраивается, а чтение вне Obx молча не обновляется
removeListener получает новое замыкание, которое никогда не добавлялось, поэтому ничего не удаляет. State остаётся подписанным после dispose: корзина держит его в памяти, а следующий notifyListeners вызывает setState у мёртвого State.
Почему: каждое () => setState(() {}) создаёт новый объект функции, а два замыкания никогда не равны по ==; ChangeNotifier.removeListener ищет слушателя через ==, не находит совпадения и молча выходит
Исправление: регистрируйте tear-off метода: _onCartChanged, взятый дважды у одного объекта, даёт равные значения, и removeListener находит то, что сохранил addListener. Подойдёт и замыкание в final-поле, если передавать один и тот же объект оба раза
Чем это грозит: корзина, часто живущая на всё приложение, держит замыкание, замыкание держит this, поэтому State и его поддерево никогда не собираются сборщиком мусора; в debug каждое изменение корзины выдаёт «setState() called after dispose()» (в release ошибку null-check), а N открытий экрана оставляют N мёртвых слушателей
Проще: ListenableBuilder(listenable: widget.cart, builder: ...) подписывается и отписывается сам; если родитель может передать другой cart, ручного слушателя нужно ещё и переносить в didUpdateWidget
Как поймать: виджет-тест, который отрисовывает бейдж, заменяет его на SizedBox, а затем меняет корзину, падает с этой ошибкой
Должны выполняться три условия: inherited-виджет должен быть новым экземпляром с новым значением, его updateShouldNotify должен вернуть true, а читатель должен реально зарегистрировать зависимость. Одно из них нарушено.
Мутация на месте: вы добавили элемент в тот же List, поэтому поле нового виджета указывает на тот же объект, что и у старого, и updateShouldNotify сравнивает его сам с собой; публикуйте новое значение (_items = [..._items, item])
Чтение без подписки: регистрирует вызывающего только dependOnInheritedWidgetOfExactType; getInheritedWidgetOfExactType просто возвращает текущий виджет и предназначен для колбэков и разового чтения. Он же работает и в initState, а dependOn... там бросает исключение, пока State не инициализирован; для чтения с перестройкой используйте didChangeDependencies или build
**updateShouldNotify возвращает false: он открывает или закрывает уведомление, поэтому весь проход останавливается и ни один зависимый не перестраивается
Новый экземпляр так и не доходит до дерева**: данные поменялись в State, который не вызвал setState, или InheritedWidget закэширован в поле; const-потомок под ним объясняет, почему больше ничего не перестраивается
Используйте InheritedModel: зависимый виджет при чтении называет аспект, и фреймворк для каждого зависимого спрашивает, изменились ли именно те аспекты, которые ему важны.
Как это работает: потомок читает через InheritedModel.inheritFrom<Prefs>(context, aspect: PrefAspect.currency), и аспект сохраняется вместе с зависимостью; при обновлении Flutter один раз вызывает updateShouldNotify, затем для каждого зависимого вызывает updateShouldNotifyDependent(old, aspects) и перестраивает только тех, кто ответил true
Flutter сам этим пользуется: MediaQuery наследует InheritedModel; поэтому MediaQuery.sizeOf(context) не реагирует на клавиатуру, а MediaQuery.of(context) перестраивается при изменении любой метрики
Аспекты должны сравниваться по значению: например, enum; объект, сравниваемый по ссылке, регистрирует зависимость, которая больше никогда не совпадёт
Фильтрует перестройки, а не работу: зависимый, который перестраивается, перестраивается целиком, а inheritFrom без аспекта зависит от всего
Обычно проще: context.select (provider) или ref.watch(p.select(...)) (Riverpod) дают ту же фильтрацию за долю кода