Тип выбирают по тому, как получается состояние и меняет ли его кто-то: Provider для вычисляемого или внедряемого значения, FutureProvider и StreamProvider для асинхронного только на чтение, Notifier для синхронного с методами, AsyncNotifier для асинхронного, которое меняет пользователь.
**Provider: репозитории, API-клиенты, значения, производные от других провайдеров. Только чтение, без состояния загрузки
FutureProvider / StreamProvider: один запрос или один стрим без мутаторов, например remote config или живая цена; обновляют его инвалидацией, а не вызовом методов
Notifier**: build() возвращает начальное значение, а ваши методы присваивают state: корзина, набор фильтров, шаг мастера
**AsyncNotifier / StreamNotifier**: то же самое с асинхронным build(); потребители получают AsyncValue, а мутаторы оборачивают IO в AsyncValue.guard
Legacy: StateProvider, StateNotifierProvider и ChangeNotifierProvider в Riverpod 3 переехали в package:flutter_riverpod/legacy.dart; новый код пишут на Notifier, и даже одно изменяемое поле оформляют как Notifier в три строки
Правило: начинайте с самого простого подходящего типа и переходите выше, как только понадобится метод или IO
FlutterSenior
Состояние на Riverpod
Провайдеры Riverpod, AsyncNotifier, ref, кодогенерация
Закрыто вопросов
0 / 25
- 0Повторить
- 2Часто спрашивают
Вопросы
watch подписывается и вызывает ребилд, read берёт текущее значение один раз без подписки, а listen подписывается, но вместо ребилда вызывает колбэк. Путаница между ними остаётся самой частой ошибкой в Riverpod.
**ref.watch(p)**: в build() виджетов и провайдеров; виджет или провайдер перезапускается при каждом изменении p
**ref.read(p)**: в колбэках вроде onPressed, обычно ref.read(p.notifier).someMethod(). В build() он вернёт значение один раз и пропустит все последующие обновления
**ref.listen(p, (prev, next) {...}): для сайд-эффектов: навигация, снекбары, логирование. Вы получаете и предыдущее значение, а ничего не перестраивается
Никогда не watch в колбэке**: подписка возникает в случайный момент и переживает сам тап
Все три дают WidgetRef. ConsumerWidget представляет собой stateless-виджет, чей build получает ref, ConsumerStatefulWidget даёт ref как поле своего ConsumerState, а Consumer служит билдером, который ограничивает ребилд поддеревом, которое он оборачивает.
**ConsumerWidget**: вариант по умолчанию: замените StatelessWidget и добавьте WidgetRef ref в build
**ConsumerStatefulWidget**: когда нужны ещё локальное состояние, контроллеры или методы жизненного цикла; ref работает в initState, dispose и колбэках, но ref.watch можно вызывать только в build (в initState используйте ref.listenManual)
**Consumer(builder: (context, ref, child) => ...)**: внутри большого виджета, который не должен перестраиваться целиком; статичную часть передайте через child, чтобы она тоже не перестраивалась
ref.invalidate(p) помечает провайдер устаревшим, и он перестраивается сразу, если есть слушатели, а иначе при следующем чтении; ref.refresh(p) делает то же, но перестраивает сразу и возвращает новое значение.
**refresh работает как invalidate плюс read**: используйте, когда нужен результат, например onRefresh: () => ref.refresh(todosProvider.future), чтобы RefreshIndicator крутился ровно столько, сколько идёт запрос; оба считаются refresh, поэтому старое значение остаётся внутри AsyncValue, а when по умолчанию пропускает ветку загрузки
**invalidate работает по принципу «выстрелил и забыл»: после успешной мутации или при выходе из аккаунта; пока никто не подписан, ничего не загружается
ref.invalidateSelf()**: то же изнутри нотифаера, например после успешного create()
AsyncValue<T> является sealed-типом, который отдают асинхронные провайдеры: AsyncData, AsyncLoading или AsyncError. Раз он sealed, компилятор заставляет UI обработать все три случая, а во время перезагрузки он может по-прежнему нести предыдущее значение.
**Отрисовка через switch**: исчерпывающий pattern matching по трём подклассам или более короткий when(data:, error:, loading:)
Сначала проверяйте значение: AsyncValue(:final value?) первым кейсом оставляет старые данные на экране во время refresh вместо мигающего спиннера
Аксессоры: value равен null, когда данных нет (до Riverpod 3 это был valueOrNull), requireValue в этом случае бросает исключение; плюс hasValue, isLoading и error
**AsyncValue.guard(() async {...})**: выполняет future и возвращает AsyncData или AsyncError, так что мутаторам не нужен try/catch
**isRefreshing / isReloading**: отличают ручное обновление от ребилда из-за изменившейся зависимости; от этой разницы зависят флаги skipLoadingOn... у when
family превращает одно объявление в набор провайдеров с ключом-аргументом: productProvider(42) и productProvider(7) дают отдельные экземпляры с отдельным закешированным состоянием. Его используют для загрузки по id, фильтра по категории или состояния на каждого тенанта.
Аргумент служит ключом мапы: ему нужно равенство по значению: int, String, enum, record или класс с == и hashCode. Объект, который сравнивается по ссылке, создаёт новый провайдер на каждом build
В ручном синтаксисе аргумент один: для нескольких передайте record вроде (query: 'cat', page: 1); кодогенерация принимает любое число параметров
Notifier получает его через конструктор: в Riverpod 3 NotifierProvider.family передаёт аргумент в конструктор нотифаера, а build() его не принимает
Это кеш: без .autoDispose каждый ключ живёт столько же, сколько контейнер, поэтому family по id пользователя или тексту поиска растёт без ограничений
Инвалидация: ref.invalidate(productProvider) сбрасывает все ключи, а ref.invalidate(productProvider(42)) только один
По умолчанию провайдер живёт столько же, сколько его контейнер. .autoDispose (поведение по умолчанию при кодогенерации) уничтожает его, как только на него никто не подписан, а это то, что нужно для всего, что привязано к экрану.
Что делает dispose: Riverpod вызывает ваши колбэки ref.onDispose и выбрасывает состояние; следующее чтение строит его с нуля
**ref.onDispose: здесь отменяют подписки, таймеры и запросы в полёте
ref.keepAlive()**: запрещает dispose, пока вы не вызовете close() у вернувшегося KeepAliveLink
**ref.onCancel / ref.onResume: срабатывают, когда уходит последний слушатель и когда кто-то возвращается; так делают «держать кеш пять минут после закрытия экрана»
Держите живым, когда: работа должна пережить быстрый уход и возврат (загрузка файла) или повторный запрос при каждом визите слишком дорог
Не ставьте autoDispose**: для состояния на всё приложение: сессии или API-клиента
riverpod_generator работает как шаг build_runner: вы помечаете функцию или класс аннотацией @riverpod, и он пишет объявление провайдера, выбирая его тип по сигнатуре.
Функция становится провайдером: @riverpod Future<User> me(Ref ref) генерирует meProvider, FutureProvider из-за типа возврата
Класс становится нотифаером: он наследует сгенерированный _$Name; Future или Stream из build делают его асинхронным
Параметры становятся family: любое число позиционных и именованных параметров, без шаблонного .family и без record-ключей
**autoDispose по умолчанию**: отключается через @Riverpod(keepAlive: true)
Зачем: один синтаксис вместо выбора из дюжины классов провайдеров, типизированные аргументы family и hot reload провайдеров с сохранением состояния
Настройка: riverpod_annotation в dependencies, riverpod_generator и build_runner в dev_dependencies, строка part 'file.g.dart';, затем dart run build_runner watch -d
ref.watch(p.select((s) => s.field)) подписывается на одно производное значение и перестраивает виджет, только когда оно меняется по ==. Так грубый провайдер сужается до того, что виджет реально показывает.
Всё решает равенство: Riverpod 3 фильтрует каждое обновление через ==; обычный класс сравнивается по ссылке, поэтому новый экземпляр всегда считается изменением. Используйте freezed, equatable или написанные вручную == и hashCode
Выбирайте примитивы или value-объекты: List, который копируется при каждом обновлении, вызывает ребилд каждый раз, потому что списки сравниваются по ссылке
**На AsyncValue**: делайте select сквозь него: p.select((s) => s.value?.name) превращает загрузку и ошибку в null
Работает и в провайдерах: ref.watch(sessionProvider.select((s) => s.userId)) не даёт провайдеру перестраиваться из-за посторонних полей
**Когда select не хватает**: разделите провайдер; настоящая проблема в одном объекте с множеством несвязанных полей
Riverpod уведомляет слушателей, только когда вы присваиваете новый state, который не == старому. add вообще ничего не присваивает, а addAgain присваивает тот же самый экземпляр списка, который всегда равен сам себе, поэтому оба обновления отфильтровываются.
**state.add(item)**: меняет список на месте и не вызывает сеттер state, так что никто ничего не узнаёт
**state = state..add(item)**: сеттер вызывается, но каскад возвращает тот же объект, а объект всегда == самому себе
Исправление требует нового значения: state = [...state, item]; удаление через state.where(...).toList(), модели через copyWith
Почему мутация на месте хуже, чем «нет обновления»: код, уже держащий список, видит, как он меняется без ребилда, и экран расходится с данными
Крайняя мера: переопределите updateShouldNotify, чтобы он возвращал true, если состояние действительно должно оставаться мутабельным, и смиритесь с тем, что фильтрации по равенству больше нет ни для одного слушателя
У SearchQuery нет == и hashCode, поэтому каждый build создаёт ключ, который не совпадает ни с одним из существующих экземпляров family. Riverpod создаёт новый провайдер, тот запускает новый запрос, а когда запрос завершается, ребилд создаёт ещё один ключ: бесконечный цикл.
Аргументы family служат ключами кеша: поиск идёт по == и hashCode, а обычный класс сравнивается по ссылке
Почему данные так и не видны: данные вызывают ребилд виджета, ребилд подписывается на совершенно новый ключ, а новый провайдер всегда начинает с AsyncLoading
**autoDispose прячет утечку: брошенные экземпляры уничтожаются, и вы видите только лавину запросов; без него каждый экземпляр оставался бы в контейнере навсегда
Исправление**: используйте record (text: text, page: 1), дайте классу равенство по значению через freezed или equatable или перейдите на кодогенерацию, где поля становятся отдельными параметрами
visibleTodosProvider читает фильтр через ref.read, который возвращает значение один раз и ни на что не подписывается. Смена фильтра не перезапускает провайдер, поэтому список сохраняет старый фильтр, пока его не пересоберёт следующее изменение todosProvider.
**Зависимость создаёт только watch**: ref.read возвращает текущее значение и тут же о нём забывает, поэтому filterProvider не входит в число того, что инвалидирует этот провайдер
Почему это похоже на задержку: сам чип подписан на filterProvider и подсвечивается сразу; список догоняет его только при следующем изменении todosProvider, поэтому баг выглядит как лаг, а не как пропущенная подписка
Исправление: ref.watch(filterProvider): в теле провайдера или в build() подписывайтесь на каждое значение, от которого зависит результат; место ref.read в колбэках и методах нотифаера (то же правило watch/read/listen)
Как поймать в следующий раз: в ProviderContainer подпишитесь на visibleTodosProvider, поменяйте только фильтр и проверьте, что список изменился; версия с багом этот тест не проходит, хотя все тесты, меняющие задачи, остаются зелёными. Также включите riverpod_lint (avoid_ref_read_inside_build): он помечает ref.read в build() и в теле провайдера
Через ref.listen в виджете. Сайд-эффект должен сработать один раз на изменение состояния, а build() может выполниться много раз для одного и того же состояния, поэтому навигация и снекбары никогда не вызываются в нём напрямую.
Где это пишется: ref.listen(p, (prev, next) {...}) внутри build() регистрирует колбэк, не делая виджет зависимым от значения, и Riverpod удаляет его вместе с виджетом
Ветвитесь по переходу: сравнивайте prev и next, чтобы эффект срабатывал только при реальном изменении: новая ошибка или оформленный заказ
**Не пускайте BuildContext в нотифаер: нотифаер, который пушит роуты, привязан к одному дереву виджетов и не тестируется юнит-тестами; отдавайте состояние, а UI пусть на него реагирует
Одноразовые результаты**: флаг «сохранено», оставшийся в состоянии, сработает снова при следующем постороннем изменении; сбрасывайте его после реакции или отслеживайте отправку через экспериментальный Mutation из Riverpod 3
Эффекты на всё приложение: редирект после выхода из аккаунта живёт в одном слушателе у корня или в redirect роутера, а не повторяется на каждом экране
initState выполняется во время фазы build, и изменившийся там провайдер должен был бы уведомить виджеты, которые в этом кадре уже построены, поэтому Riverpod бросает исключение, а не показывает дерево с двумя противоречивыми версиями состояния.
Что запрещено: присваивать state, вызывать метод нотифаера или ref.invalidate во время build, initState или didChangeDependencies
Читать можно: ref.read(p) в initState разрешён; ограничен только ref.watch, он работает лишь в build
**Future.microtask(...) работает как народное лекарство: ошибка пропадает, но запись срабатывает при каждом монтировании и прячет настоящую проблему дизайна
Пусть провайдер инициализирует себя сам**: build() и есть инициализатор, так что задайте начальное значение аргументом family или override в ProviderScope, и ни одному виджету не придётся ничего туда вталкивать
Пишите по событиям: onPressed, onChanged или колбэк ref.listen выполняются после фазы build, и запись должна быть именно там
Ожидайте future источников внутри производного провайдера, а не сопоставляйте два объекта AsyncValue друг с другом.
**ref.watch(p)**: даёт AsyncValue, правильную форму для виджета, которому нужно отрисовать загрузку и ошибку
**ref.watch(p.future)**: даёт Future, правильную форму для другого провайдера, потому что его можно просто дождаться через await
Подписка всё равно есть: watch на .future регистрирует зависимость, и производный провайдер перезапускается при изменении любого источника
Ошибки не нужно прокидывать вручную: await пробрасывает исключение, оно выходит из build(), и Riverpod превращает его в AsyncError; при (a, b).wait ошибка приходит обёрнутой в ParallelWaitError, поэтому разворачивайте её, если проверяете исходный тип
**Никогда не ref.read(p.future) здесь: read не подписывается, и производное значение молча устаревает
Параллельно или последовательно**: (a, b).wait, когда загрузки независимы, и обычные await по очереди, когда второй нужен результат первой
Старые строки по-прежнему лежат внутри AsyncValue; спиннер появляется потому, что обновление помечено как reload, а when() по умолчанию рисует ветку загрузки на reload. Обычно причина в написанном вручную state = const AsyncLoading().
Riverpod сохраняет предыдущее значение: новый AsyncLoading наследует последние данные, так что value по-прежнему возвращает старый список
Refresh и reload: ref.refresh и ref.invalidate дают refresh (isRefreshing), который when по умолчанию пропускает; изменившаяся зависимость или ручной AsyncLoading дают reload (isReloading), который when показывает, если не указать skipLoadingOnReload: true
Не объявляйте загрузку для фоновой работы: присваивайте результат сразу: state = await AsyncValue.guard(...)
Рисуйте сначала данные: switch с AsyncValue(:final value?) первым кейсом показывает строки, когда они есть, а isLoading может управлять тонким прогресс-баром сверху
**Для RefreshIndicator**: возвращайте ref.refresh(p.future) из onRefresh, чтобы индикатор крутился ровно столько, сколько идёт запрос
Создайте ProviderContainer с override для зависимостей, держите слушателя на тестируемом провайдере и вызывайте настоящий нотифаер через container.read(p.notifier). Без слушателя autoDispose-провайдер уничтожается между вашими вызовами, и следующее чтение строит новый.
**ProviderContainer.test(overrides: [...])**: в Riverpod 3; он сам уничтожается в конце теста. На 2.x используйте ProviderContainer(...) плюс addTearDown(container.dispose)
Сначала подпишитесь: container.listen(p, (_, _) {}) заменяет виджет, который в приложении держит состояние живым
Почему это выглядит случайным: для dispose нужен зазор, и его даёт любой await внутри метода; Riverpod 3 затем бросает UnmountedRefException, когда метод снова трогает ref
Асинхронные нотифаеры: сначала await container.read(p.future), чтобы build() завершился, и только потом вызывайте методы
Подменяйте репозиторий, а не нотифаер: тестируемая логика остаётся настоящей; виджет-тесты передают те же overrides: в ProviderScope
Подключите ProviderObserver к корневому ProviderScope. Riverpod будет по порядку сообщать о каждом провайдере, который создан, обновлён, уничтожен или упал, без единой строки логирования внутри самих провайдеров.
Хуки: didAddProvider, didUpdateProvider, didDisposeProvider и providerDidFail
Подключается один раз: ProviderScope(observers: [StateLogger()], child: App()); ProviderContainer принимает такой же список observers:, так что логировать можно и в тестах
Давайте провайдерам имена: передавайте name: или пусть его выведет кодогенерация, иначе лог превращается в стену безымянных записей
Логируйте разницу, потом сужайте: didUpdateProvider даёт предыдущее и новое значение, а лог на всё приложение на нагруженном экране превращается в шум; оставьте два-три причастных провайдера
Для одного подозреваемого: listenSelf внутри нотифаера дешевле обсервера и лежит рядом с кодом, который меняет состояние
Override заменяет то, во что провайдер разрешается внутри одного контейнера: корневой ProviderScope нужен для подмен на всё приложение и фейков в тестах, вложенный ProviderScope для поддерева. Провайдер остаётся тем же ключом; меняется только его реализация.
**overrideWithValue(v)**: заменяет значение константой: репозитории, конфиг, SharedPreferences, загруженные в main()
**overrideWith(...)**: заменяет функцию build или, для нотифаеров, класс: cartProvider.overrideWith(DemoCart.new)
Корневые override: конфиг флейвора, зависимости, асинхронно созданные до runApp, и фейки в виджет-тестах
**Вложенным скоупам нужны dependencies**: подменяемый провайдер объявляет dependencies (часто const []), а каждый провайдер, который его читает, указывает его в своих dependencies; иначе такой провайдер разрешается в корне и никогда не видит значение из скоупа; в debug-сборке к тому же срабатывает assert «one of its dependencies were overridden but the provider is not». riverpod_lint подсвечивает пропущенные записи
Классическое применение: currentItemProvider, подменённый для каждой строки списка, чтобы виджеты строк были const и читали свой элемент без аргумента family
Это не кнопка сброса: перемонтирование ProviderScope с новым ключом выбрасывает всё, что под ним; это подходит для смены тенанта, но не для очистки одной фичи
Когда экран закрывается, autoDispose-провайдер уничтожается, но ваш await продолжает выполняться. Код после него обращается к мёртвому Ref, и в Riverpod 3 это бросает UnmountedRefException.
Riverpod ничего не отменяет: future в Dart нельзя отменить, поэтому запрос завершается, и продолжение всё равно выполняется
**AsyncValue.guard не спасает**: он оборачивает сам вызов, а не присваивание state и обращения к ref, которые идут после него
**Проверяйте ref.mounted после каждого await**: выходите раньше, чем тронете ref или state
**Регистрируйте настоящую отмену в ref.onDispose**: CancelToken из Dio, таймеры, подписки; брошенный запрос тогда ничего не стоит
Когда работа обязана завершиться (загрузка файла, платёж): вызовите ref.keepAlive() в начале и закройте link в finally
**Не убирайте просто autoDispose**: вы обменяете падение на неограниченный кеш и устаревшие экраны
Нотифаер делал watch на сессию внутри build(), поэтому обновление токена выбросило состояние и перезапустило build() с нуля. Черновик записывался мутатором, а повторная инициализация ничего из этого не сохраняет.
**build() означает пересчёт, а не конструктор**: каждый ref.watch в нём создаёт подписку, которая его перезапускает, а результат нового запуска заменяет состояние, включая правки пользователя
**Перезапускает только watch**: ref.read и ref.listen внутри build() такой зависимости не создают
Сузьте зависимость: ref.watch(sessionProvider.select((s) => s.userId)) перезапускается при реальной смене аккаунта, а не при каждой ротации токена
Читайте изменчивые значения в момент вызова: ref.read(sessionProvider).token внутри save(), а не watch в build()
Храните черновик в отдельном провайдере: серверные данные в одном, правки пользователя в другом; перезагрузка снизу тогда становится решением о слиянии, а не тихой потерей данных
build() отвечает на вопрос «каково это состояние при данных входах»; то, что ввёл пользователь, является отдельным входом, а не тем, что можно пересчитать.
Riverpod 3 автоматически повторяет упавшие провайдеры: по умолчанию до 10 раз, с экспоненциальной задержкой от 200 мс до 6,4 с. Ответ 400 сам не исправится, поэтому экран продолжает слать запрос, пока вы не скажете Riverpod, какие ошибки стоит повторять.
Касается всего, что брошено из build: FutureProvider, StreamProvider, AsyncNotifier.build(); сетевой лог заполняется, даже если экран ошибки выглядит статичным
Настраивается на скоупе: ProviderScope(retry: (retryCount, error) => ...) возвращает задержку до следующей попытки или null, чтобы остановиться; собственный retry: провайдера важнее скоупа
Мутаторы не повторяются: ошибка внутри метода нотифаера не считается падением build, поэтому повторяется только путь загрузки
Повторяйте только транспортные сбои: таймауты, обрывы соединения, 5xx, офлайн; никогда 4xx, ошибку парсинга или отказ авторизации
**Не трогайте ProviderException**: ошибка, пришедшая из зависимости, приходит обёрнутой в него, и политика по умолчанию её пропускает, потому что упавший провайдер повторяет себя сам; сохраните это правило в своей политике, иначе один сбой размножится вниз по графу
Riverpod 3.0 (сентябрь 2025) свёл API к обычным Notifier и AsyncNotifier, сделал Ref нетипизированным и поменял поведение в рантайме: фильтрация по == везде, автоматический retry, пауза слушателей у скрытых виджетов и Ref, который бросает исключение после dispose.
Что перестаёт компилироваться
Legacy-провайдеры: StateProvider, StateNotifierProvider и ChangeNotifierProvider теперь импортируются из package:flutter_riverpod/legacy.dart
Варианты классов удалены: AutoDisposeNotifier, FamilyNotifier и подобные становятся обычным Notifier; аргумент family переезжает в конструктор нотифаера
**AsyncValue**: теперь sealed; valueOrNull переименован в value, а старый бросающий value удалён
**Ref**: без параметра типа и без сгенерированного FooRef; ref.state и ref.listenSelf переехали в нотифаер
**ProviderObserver**: колбэки получают ProviderObserverContext
Что компилируется, но ведёт себя иначе
**Обновления фильтруются по ==**: новое состояние, равное старому (copyWith() без изменений, стрим с повторяющимся значением), больше не уведомляет слушателей; в 2.x большинство провайдеров сравнивали по ссылке
**AsyncValue.value**: теперь возвращает null при загрузке или ошибке вместо исключения, поэтому старые вызовы .value компилируются, но больше не падают явно
Автоматический retry: упавшие провайдеры перестраиваются с задержкой, так что 4xx может повториться до десяти раз, если не задать retry
Пауза слушателей: виджеты под выключенным TickerMode, например роуты, перекрытые текущим, перестают получать обновления, пока снова не станут видимыми
**Уничтоженный Ref бросает исключение**: UnmountedRefException после await, поэтому проверяйте ref.mounted
Ошибки из зависимостей приходят обёрнутыми в ProviderException, что меняет проверки is в обработчиках ошибок
Новое и экспериментальное
**ProviderContainer.test() для тестов, Mutation для отслеживания состояния отправки (в процессе, успех, ошибка) и офлайн-персистентность** для кеширования состояния провайдеров на диске
Как мигрировать
Обновитесь, исправьте ошибки компиляции поиском с заменой, затем пройдите по списку изменений поведения (сначала политика retry и равенство ваших классов состояния) и прогоните виджет-тесты перед релизом
Создайте ProviderContainer сами, пользуйтесь им из обычного Dart и передайте тот же контейнер в дерево виджетов через UncontrolledProviderScope. Обычный ProviderScope строит собственный контейнер, и именно наличие двух контейнеров дублирует состояние.
Контейнер и есть состояние: провайдер лишь ключ, поэтому два контейнера означают два auth-нотифаера, два сокета, два кеша и записи, которых другая сторона никогда не увидит
**BuildContext не нужен**: container.read(p) и container.listen(p, cb) относятся к API вне виджетов, а ref внутри провайдера даёт взгляд на тот же контейнер
**Бутстрап в main()**: создайте контейнер с override, которым нужен await, прогрейте то, чего ждёт сплэш, через await container.read(p.future), затем передайте его в runApp
Фоновый изолят до него не дотянется: у него своя память, поэтому создайте там короткоживущий контейнер, уничтожьте его, а UI пусть подхватит то, что он записал в хранилище
Никогда не кладите контейнер в статическое поле: это тот самый service locator, от которого Riverpod избавляет, и он лишает смысла override и тесты
Глобальная переменная не хранит состояния. Объект провайдера представляет собой неизменяемый дескриптор (фабрика плюс модификаторы), а значение, которое он описывает, живёт в том ProviderContainer, который его читает.
**Ближе к Key, чем к синглтону: у него нет поля состояния, и ничего не создаётся, пока контейнер его не прочитает
Один дескриптор, много значений**: у одного и того же counterProvider одно значение в запущенном приложении, другое под вложенным ProviderScope и свежее в каждом тестовом контейнере, так что тесты не протекают друг в друга
Тип живёт на переменной: ref.watch(counterProvider) возвращает int на этапе компиляции, поэтому, в отличие от package:provider, в рантайме не бывает ошибки «провайдер не найден»
Внедрение означает подстановку, а не мутацию: overrides: заменяет реализацию для одного контейнера, и потом ничего не нужно возвращать на место
Настоящий риск поведенческий: раз имя доступно везде, люди начинают тянуться к контейнеру отовсюду; держите один контейнер на изолят и передавайте Ref вниз