The post has been translated automatically. Original language: Russian
Flutter is very fond of arguing about which is better: Bloc, Riverpod, Provider, Cubit, InheritedWidget, ValueNotifier, or even pure setState() and not making it up. But in real projects, it's usually not the library that falls apart. The understanding of where the truth about the condition lives is falling apart.
That is why the same problems occur in completely different stacks: the screen shows old data, the filter lives its own life, the local state unexpectedly affects another screen, and the loading, error, and list of items diverge from each other as if they belong to different applications.
There is almost always one root: one fact has two or three hosts in the system.
The main idea
In Flutter, the problem of state is usually not what you manage the state with, but what exactly you consider the state to be and where exactly you store it.
To put it quite harshly:
Every fact in an application should have one source of truth, a clear lifecycle, and a predictable area of influence.
If this is not the case, any library starts to seem bad.
What I call the "boundaries of truth"
The boundaries of truth are the answer to three questions:
- Who owns this fortune?
- How long should it live?
- Who has the right to change it?
For example:
- whether the keyboard is open, whether a tab is selected, whether a block is opened — this is usually the local UI state.;
- is the user logged in, what is in the trash, which active workspace is the app/session state;
- the list of products, the user profile, and the list of orders from the API are server state;
- the search bar, the selected filter, and pagination on a specific screen are the screen state;
- visibleItems, sortedUsers, and isSubmitEnabled are often not a state at all, but a derived value.
And that's where most of the errors begin: the derived value is stored as a separate truth, the UI state is raised too high, the server state is pushed into the on-screen controller, and the app-wide state suddenly turns out to be a local field in one widget.
The most common mistake is to store both the original data and its derivative copy.
Here is a typical example.
class CatalogController extends ChangeNotifier {
List<Product> products = [];
List<Product> filteredProducts = [];
String query = '';
Category? selectedCategory;
bool isLoading = false;
Future<void> load() async {
isLoading = true;
notifyListeners();
products = await api.fetchProducts();
filteredProducts = _applyFilters(products, query, selectedCategory);
isLoading = false;
notifyListeners();
}
void setQuery(String value) {
query = value;
filteredProducts = _applyFilters(products, query, selectedCategory);
notifyListeners();
}
void setCategory(Category? value) {
selectedCategory = value;
filteredProducts = _applyFilters(products, query, selectedCategory);
notifyListeners();
}
}At first glance, everything is fine. But in fact, there are already two truths here.:
- products
- filteredProducts
And filteredProducts is not an independent entity. It's just a function from products + query + selectedCategory.
It is such constructions that then begin to leak at the seams: the data has been updated, but the filtering has not been recalculated; the filter has been reset, but the list has not been updated; one method forgot to call _applyFilters() and now the UI shows the old state.
That is, the problem is not with ChangeNotifier. The problem is that you saved the result of the calculation as a separate truth.
It looks much more stable like this:
class CatalogController extends ChangeNotifier {
List<Product> products = [];
String query = '';
Category? selectedCategory;
bool isLoading = false;
List<Product> get visibleProducts {
return products.where((product) {
final matchesQuery =
query.isEmpty || product.title.toLowerCase().contains(query.toLowerCase());
final matchesCategory =
selectedCategory == null || product.category == selectedCategory;
return matchesQuery && matchesCategory;
}).toList();
}
Future<void> load() async {
isLoading = true;
notifyListeners();
products = await api.fetchProducts();
isLoading = false;
notifyListeners();
}
void setQuery(String value) {
query = value;
notifyListeners();
}
void setCategory(Category? value) {
selectedCategory = value;
notifyListeners();
}
}There's only one truth now:
- data,
- request,
- the selected category.
And visibleProducts is already a derived value.
Yes, if the list is large, the calculation can be memoized or placed in a separate layer. But architecturally it's already much cleaner.: you are not manually synchronizing the two “truths”.
The second common mistake is to raise the state higher than necessary.
In Flutter, it's easy to get scared of the “local state” and drag everything up to the global provider, app controller, or shared store.
But the higher you raise the state, the bigger its kill zone.
For example, this type of state should almost always live locally.:
- the current tab on the screen;
- Is the ExpansionTile open?;
- the entered text in a specific field;
- is chip selected in the current UI block?;
- is the password visible;
- the index of the current PageView.
When such things go to the global store, the code becomes not more “architectural”, but heavier: unnecessary dependencies appear, unnecessary rebuild, it is more difficult to reuse the screen, it is more difficult to test, and it is unclear when this state should die at all.
A very useful rule:
if the state should disappear along with the screen, it almost always does not need to be dragged to the app-wide layer.
The third mistake is to treat server data as a regular screen state.
This is one of the most overlooked problems.
Let's say the screen loads a list of orders. This is often done like this: in the screen-level controller, they put orders, isLoading, error, page, hasMore, isRefreshing.
It looks comfortable at first. But very quickly it turns out that:
- the other screen wants this data too;
- need a refetch;
- need a cache;
- An optimistic mutation is needed;
- We need stale states;
- need a retry;
- it is necessary not to lose the data when returning back.
And here it becomes clear that this is no longer just a “screen condition". This is a server state, which has a different lifecycle and different rules.
Server state is not just “data + loading". This:
- remote source;
- the likelihood of obsolescence;
- cache;
- synchronization;
- re-uploading;
- network errors;
- partial relevance.
If you store it as a regular field in StateNotifier or Cubit, you can quickly get fragile code where the UI begins to take on the responsibilities of the query/cache layer.
The fourth mistake: mixing workflow state and domain state
There is another type of state that is often confused with the others: workflow state.
For example:
- registration form in step 2 of 4;
- The payment process;
- multi-step wizard;
- optimistic update;
- the status is “submit".
This state is not exactly a local UI, but it is also not a full-fledged domain state of the application. It lives exactly inside a specific process.
The problem begins when it is mixed with the domain model.
A bad example: isSaving, IsDirty, step, ValidationError, and isSubmitting are inserted into the user's model.
At this point, the domain entity ceases to be a domain entity and becomes a container for everything in the world.
It's much cleaner to keep it separate:
- the domain model;
- the status of the process of working with it.
Why are libraries being scolded in the wrong place because of this?
When a project has problems with the state, they usually say this:
- Riverpod is too complicated;
- The block is too verbose;
- Provider is too fragile;
- Cubit is too simple;
- setState() does not scale.
But more often than not, the library just honestly reflects a problem that already exists in the application model.
If one fact has two owners, it will break on both Riverpod and Block. If the local UI state lives globally, it will be inconvenient on both Provider and Cubit. If you store the derived data as an independent truth, sooner or later it will break down in any approach.
The library affects ergonomics. But stability is more often determined by how you cut the semantic zones of the state.
A practical model that almost always helps
When in doubt, try decomposing the state like this:
class="heading-3">1. Ephemeral UI stateLives within the widget or screen. Examples:
- selected tab;
- expanded/collapsed;
- local text field;
- toggle;
- scroll position.
2. Screen state
Lives within a specific screen scenario. Examples:
- query;
- selected filters;
- page number;
- sort mode;
- currently selected item id.
3. Server state
It lives in the data/query/repository layer and obeys the logic of the remote source. Examples:
- user profile;
- orders list;
- notifications feed;
- cached remote config.
4. App/session state
It lives longer than one screen. Examples:
- authenticated user;
- active organization;
- cart;
- theme mode;
- feature flags.
5. Derived state
It is not stored as a separate truth at all, but is calculated from other values. Examples:
- visibleItems;
- totalPrice;
- isFormValid;
- canSubmit;
- groupedMessages.
This is not a dogma, but as a working framework it shows very quickly where your architectural cracks begin.
Signs that the boundaries of truth have already been broken
There are several very characteristic symptoms:
- you often synchronize one state with another manually.;
- the same value is stored in two places;
- The UI shows outdated data after navigating back;
- the screen knows too much about cache and API;
- domain models have been overgrown with the isLoading, IsDirty, and IsExpanded flags;
- For some reason, a small local change requires a global update.;
- There are no fewer problems when changing the library.
If you recognize a project by these signs, it's almost certainly not the selected state management tool.
What works in practice
Here are the most useful rules:
One fact — one owner. Don't store the same truth in multiple places.
The derived state should not pretend to be the original state. If the value can be calculated, do not rush to save it.
The state should live exactly as long as the script needs. No longer and no shorter.
The local does not need to be globalized. And vice versa: app-wide things should not be hidden in one screen.
Server data is a special class of state. They require separate treatment for freshness, retry, cache, and synchronization.
The main thing
In Flutter, the chaos doesn't start when you select the “wrong” library. It starts when the application stops understanding where the truth actually lives.
That is why good state management is not so much a choice between Bloc and Riverpod, but rather a discipline in answering simple questions.: who owns this value, who has the right to change it, how long it lives, and whether it is even an independent entity.
As long as these answers are clear, almost any library works fine. When they are blurred, no one can save them.
Во Flutter очень любят спорить о том, что лучше: Bloc, Riverpod, Provider, Cubit, InheritedWidget, ValueNotifier или вообще чистый setState() и не выдумывать. Но в реальных проектах разваливается обычно не библиотека. Разваливается понимание, где живёт правда о состоянии.
Именно поэтому одни и те же проблемы встречаются в совершенно разных стеках: экран показывает старые данные, фильтр живёт своей жизнью, локальное состояние неожиданно влияет на другой экран, а загрузка, ошибка и список элементов расходятся между собой так, будто принадлежат разным приложениям.
Почти всегда корень один: у одного факта в системе оказалось два или три хозяина.
Главная мысль
Во Flutter проблема состояния обычно не в том, чем вы управляете стейтом, а в том, что именно вы считаете state и где именно это храните.
Если сказать совсем жёстко:
у каждого факта в приложении должен быть один источник правды, понятный жизненный цикл и предсказуемая зона влияния.
Если этого нет, любая библиотека начинает казаться плохой.
Что я называю "границами правды"
Границы правды — это ответ на три вопроса:
- Кому принадлежит это состояние?
- Как долго оно должно жить?
- Кто имеет право его менять?
Например:
- открыта ли клавиатура, выбрана ли вкладка, раскрыт ли блок — это обычно локальное UI state;
- авторизован ли пользователь, что лежит в корзине, какой активный workspace — это app/session state;
- список товаров, профиль пользователя, список заказов из API — это server state;
- строка поиска, выбранный фильтр, пагинация на конкретном экране — это screen state;
- visibleItems, sortedUsers, isSubmitEnabled — это часто вообще не state, а производное значение.
И вот тут большинство ошибок и начинается: производное значение начинают хранить как отдельную правду, UI state поднимают слишком высоко, server state засовывают в экранный controller, а app-wide состояние внезапно оказывается локальным полем в одном виджете.
Самая частая ошибка: хранить и исходные данные, и их производную копию
Вот типичный пример.
class CatalogController extends ChangeNotifier {
List<Product> products = [];
List<Product> filteredProducts = [];
String query = '';
Category? selectedCategory;
bool isLoading = false;
Future<void> load() async {
isLoading = true;
notifyListeners();
products = await api.fetchProducts();
filteredProducts = _applyFilters(products, query, selectedCategory);
isLoading = false;
notifyListeners();
}
void setQuery(String value) {
query = value;
filteredProducts = _applyFilters(products, query, selectedCategory);
notifyListeners();
}
void setCategory(Category? value) {
selectedCategory = value;
filteredProducts = _applyFilters(products, query, selectedCategory);
notifyListeners();
}
}На первый взгляд всё нормально. Но по факту здесь уже две правды:
- products
- filteredProducts
А filteredProducts — это не самостоятельная сущность. Это просто функция от products + query + selectedCategory.
Именно такие конструкции потом начинают течь по швам: данные обновились, а фильтрация не пересчиталась; фильтр сбросили, а список не обновился; один метод забыл вызвать _applyFilters() и теперь UI показывает старое состояние.
То есть проблема не в ChangeNotifier. Проблема в том, что вы сохранили результат вычисления как отдельную правду.
Гораздо устойчивее выглядит вот так:
class CatalogController extends ChangeNotifier {
List<Product> products = [];
String query = '';
Category? selectedCategory;
bool isLoading = false;
List<Product> get visibleProducts {
return products.where((product) {
final matchesQuery =
query.isEmpty || product.title.toLowerCase().contains(query.toLowerCase());
final matchesCategory =
selectedCategory == null || product.category == selectedCategory;
return matchesQuery && matchesCategory;
}).toList();
}
Future<void> load() async {
isLoading = true;
notifyListeners();
products = await api.fetchProducts();
isLoading = false;
notifyListeners();
}
void setQuery(String value) {
query = value;
notifyListeners();
}
void setCategory(Category? value) {
selectedCategory = value;
notifyListeners();
}
}Теперь правда одна:
- данные,
- запрос,
- выбранная категория.
А visibleProducts — это уже производное значение.
Да, если список большой, вычисление можно мемоизировать или вынести в отдельный слой. Но архитектурно это уже гораздо чище: вы не синхронизируете две “истины” вручную.
Вторая частая ошибка: поднимать состояние выше, чем нужно
Во Flutter легко испугаться “локального состояния” и утащить всё наверх: в глобальный provider, app controller или shared store.
Но чем выше вы поднимаете state, тем больше у него зона поражения.
Например, состояние такого типа почти всегда должно жить локально:
- текущая вкладка на экране;
- открыт ли ExpansionTile;
- введённый текст в конкретном поле;
- выбран ли chip в текущем UI-блоке;
- виден ли пароль;
- индекс текущего PageView.
Когда такие вещи уезжают в глобальный store, код становится не “архитектурнее”, а тяжелее: появляются лишние зависимости, лишние rebuild, труднее переиспользовать экран, сложнее тестировать и непонятно, когда это состояние вообще должно умирать.
Очень полезное правило:
если состояние должно исчезнуть вместе с экраном, почти всегда его не надо тащить в app-wide слой.
Третья ошибка: серверные данные воспринимать как обычный экранный state
Это одна из самых недооценённых проблем.
Допустим, экран загружает список заказов. Часто это делают так: в screen-level controller кладут orders, isLoading, error, page, hasMore, isRefreshing.
Сначала выглядит удобно. Но очень быстро оказывается, что:
- другой экран тоже хочет эти данные;
- нужен refetch;
- нужен cache;
- нужна оптимистичная мутация;
- нужны stale states;
- нужен retry;
- нужно не потерять данные при возврате назад.
И вот тут становится видно, что это уже не просто “состояние экрана”. Это server state, у которого другой жизненный цикл и другие правила.
Server state — это не просто “данные + loading”. Это:
- удалённый источник;
- вероятность устаревания;
- кэш;
- синхронизация;
- повторная загрузка;
- ошибки сети;
- частичная актуальность.
Если хранить его как обычное поле в StateNotifier или Cubit, можно быстро получить хрупкий код, где UI начинает тащить на себе обязанности query/cache-слоя.
Четвёртая ошибка: смешивать workflow state и доменное состояние
Есть ещё один вид состояния, который часто путают с остальными: workflow state.
Например:
- форма регистрации на шаге 2 из 4;
- процесс оплаты;
- multi-step wizard;
- optimistic update;
- статус “идёт submit”.
Это состояние не совсем локальное UI, но и не полноценное доменное состояние приложения. Оно живёт ровно внутри конкретного процесса.
Проблема начинается, когда его смешивают с доменной моделью.
Плохой пример: в модель пользователя засовывают isSaving, isDirty, step, validationError, isSubmitting.
В этот момент доменная сущность перестаёт быть доменной сущностью и становится контейнером всего на свете.
Гораздо чище держать отдельно:
- доменную модель;
- состояние процесса работы с ней.
Почему из-за этого ругают библиотеки не по адресу
Когда у проекта проблемы со стейтом, обычно говорят так:
- Riverpod слишком сложный;
- Bloc слишком многословный;
- Provider слишком хрупкий;
- Cubit слишком простой;
- setState() не масштабируется.
Но чаще всего библиотека просто честно отражает проблему, которая уже есть в модели приложения.
Если у одного факта два владельца, это сломается и на Riverpod, и на Bloc. Если локальный UI state живёт глобально, это будет неудобно и на Provider, и на Cubit. Если производные данные вы храните как самостоятельную правду, рано или поздно это сломается вообще в любом подходе.
Библиотека влияет на ergonomics. Но устойчивость чаще определяется тем, как вы нарезали смысловые зоны состояния.
Практическая модель, которая почти всегда помогает
Когда сомневаетесь, попробуйте разложить состояние так:
1. Ephemeral UI state
Живёт в пределах виджета или экрана. Примеры:
- selected tab;
- expanded/collapsed;
- local text field;
- toggle;
- scroll position.
2. Screen state
Живёт в пределах конкретного сценария экрана. Примеры:
- query;
- selected filters;
- page number;
- sort mode;
- currently selected item id.
3. Server state
Живёт в data/query/repository слое и подчиняется логике удалённого источника. Примеры:
- user profile;
- orders list;
- notifications feed;
- cached remote config.
4. App/session state
Живёт дольше одного экрана. Примеры:
- authenticated user;
- active organization;
- cart;
- theme mode;
- feature flags.
5. Derived state
Вообще не хранится как отдельная истина, а считается из других значений. Примеры:
- visibleItems;
- totalPrice;
- isFormValid;
- canSubmit;
- groupedMessages.
Это не догма, но как рабочая рамка она очень быстро показывает, где у вас начинаются архитектурные трещины.
Признаки того, что границы правды уже сломаны
Есть несколько очень характерных симптомов:
- вы часто синхронизируете одно состояние с другим вручную;
- одно и то же значение хранится в двух местах;
- UI показывает устаревшие данные после навигации назад;
- экран знает слишком много про кэш и API;
- доменные модели обросли флагами isLoading, isDirty, isExpanded;
- маленькое локальное изменение почему-то требует глобального обновления;
- при смене библиотеки проблем меньше не становится.
Если вы узнаёте проект по этим признакам, дело почти наверняка не в выбранном state management tool.
Что работает на практике
Вот самые полезные правила:
Один факт — один владелец. Не храните одну и ту же истину в нескольких местах.
Производное состояние не должно притворяться исходным. Если значение можно посчитать, не спешите его сохранять.
Состояние должно жить ровно столько, сколько нужно сценарию. Не дольше и не короче.
Локальное не надо глобализировать. И наоборот: app-wide вещи не должны прятаться в одном экране.
Серверные данные — это особый класс состояния. Они требуют отдельного отношения к freshness, retry, cache и синхронизации.
Главное
Во Flutter хаос начинается не тогда, когда вы выбрали “не ту” библиотеку. Он начинается тогда, когда приложение перестаёт понимать, где на самом деле живёт правда.
Именно поэтому хороший state management — это не столько выбор между Bloc и Riverpod, сколько дисциплина в ответах на простые вопросы: кто владеет этим значением, кто имеет право его менять, сколько оно живёт и является ли оно вообще самостоятельной сущностью.
Пока эти ответы ясны, почти любая библиотека работает нормально. Когда они размыты — не спасает уже никакая.