Flutter apps rarely start with complicated state.
At first, a Bloc or Cubit state often looks harmless:
class KidState {
final Kid? kid;
final bool isLoading;
final StateError? error;
const KidState({
required this.kid,
required this.isLoading,
required this.error,
});
}
This works until the screen becomes real.
You add initial loading. Then pull-to-refresh. Then a cached value. Then an empty state. Then an error that should not erase the old content. Then a retry. Then one more field with the same lifecycle.
Now the state is not simple anymore. It is implicit.
// What does this mean?
kid != null && isLoading && error != null
Is the kid loaded? Is the screen refreshing? Did the refresh fail? Should the UI show the old kid, the error, or a full-screen loader?
The problem is not Bloc. The problem is that the data lifecycle is hidden behind nulls and flags.
That distinction matters for adoption.
Bloc or Cubit should coordinate the screen: user actions, refreshes, navigation, analytics, and when to emit a new state. It should not have to reinvent a full data-lifecycle model for every async field.
The repository or API layer should fetch data and return a success or failure. It should not know whether the UI will show a shimmer, a tab, a toast, or an empty placeholder.
StatefulData sits between those responsibilities. It describes the state of the data itself.
Declarative Programming Patterns
Declarative Programming Patterns means writing code where every meaningful outcome is explicit: no nullable state, no hidden default branches, no ignored failures, and no ambiguous "maybe" values.
For async data, that means the lifecycle should be part of the type.
With stateful_data, one field can describe the whole lifecycle:
StatefulData<Kid, StateError> kid;
This value is always in exactly one meaningful state:
UninitializedLoadingEmptyReadyDirtyUpdatingFailure
The code no longer asks "is this null because it failed, because it is empty, or because we never loaded it?"
The state tells you.
The Old Way
A typical Cubit state starts like this:
class KidState {
final Kid? kid;
final bool isLoading;
final StateError? error;
const KidState({
required this.kid,
required this.isLoading,
required this.error,
});
KidState copyWith({
Kid? kid,
bool? isLoading,
StateError? error,
}) {
return KidState(
kid: kid ?? this.kid,
isLoading: isLoading ?? this.isLoading,
error: error ?? this.error,
);
}
}
This has several hidden problems.
copyWith cannot set kid or error back to null without extra sentinel logic. isLoading does not explain whether this is first load or refresh. error can exist together with old data, but the type does not say whether that is allowed.
The state permits combinations that the product may not actually support.
The StatefulData Way
Use one lifecycle field:
class KidState {
final StatefulData<Kid, StateError> kid;
const KidState({
required this.kid,
});
const KidState.initial() : kid = const Uninitialized();
KidState copyWith({
StatefulData<Kid, StateError>? kid,
}) {
return KidState(
kid: kid ?? this.kid,
);
}
}
Now the Cubit can move through named transitions:
emit(state.copyWith(kid: state.kid.toLoading()));
final result = await repository.fetchKid();
emit(state.copyWith(kid: result));
If the request fails:
emit(
state.copyWith(
kid: state.kid.toFailure(
StateError('Failed to load kid info'),
),
),
);
The difference is small in code, but large in meaning.
toLoading() can preserve the best previous value. toFailure() can preserve the best previous value. Ready(kid) means there is a usable value. Empty() means the request completed and there is nothing to show. Uninitialized() means loading has not happened yet.
These are not flags. They are states.
Bloc Still Does the Bloc Job
StatefulData does not replace Bloc or Cubit.
Bloc and Cubit still coordinate actions:
- user taps;
- screen refreshes;
- repository calls;
- navigation;
- analytics;
- state emission.
StatefulData only models the lifecycle of one async value.
This is the separation of concerns:
Repository/API:
fetches or saves data
returns success, empty, or failure
StatefulData:
describes the lifecycle of that data
keeps previous value context during loading or failure
Bloc/Cubit:
coordinates screen behavior
decides when to load, refresh, retry, or emit
Widget:
renders the lifecycle
When UI state becomes responsible for keeping data state, responsibilities start to leak. The Cubit ends up remembering API outcomes through flags. The widget starts guessing what null means. The repository may be pressured to return UI-shaped data.
StatefulData gives data state its own small home, so the layers can stay simpler.
That makes it useful inside normal Bloc/Cubit state:
class ProfileState {
final StatefulData<Kid, StateError> kid;
final StatefulData<List<Product>, StateError> products;
final int selectedTabIndex;
const ProfileState({
required this.kid,
required this.products,
required this.selectedTabIndex,
});
}
The tab index is ordinary UI state. The async values get explicit lifecycle state.
That boundary keeps the pattern clean.
A Practical Rule
If a field can be:
- not loaded yet;
- loading;
- loaded;
- empty;
- refreshing;
- failed;
- locally edited;
then it probably should not be modeled as:
T? value;
bool isLoading;
Object? error;
It should be modeled as:
StatefulData<T, E> value;
That is the first StatefulData pattern to adopt.
You can try it gradually. Pick one field that currently uses T?, isLoading, and error, replace only that field with StatefulData<T, E>, and keep the rest of your Bloc/Cubit architecture unchanged.
What the UI Starts to Look Like
Once the data lifecycle is explicit, the widget can render that lifecycle directly:
state.kid.statefulBuilder(
shimmer: () => const KidHeaderShimmer(),
emptyBuilder: () => const SizedBox.shrink(),
failureBuilder: (failure) => KidHeaderError(
message: failure.message,
),
builder: (kid, inProgress, {error}) {
return KidHeader(
kid: kid,
isRefreshing: inProgress,
refreshError: error,
);
},
);
This is the adoption-friendly part: the UI no longer needs to reverse-engineer state from kid == null, isLoading, and error. It receives one lifecycle value and maps each outcome to a deliberate widget.
Install
dependencies:
stateful_data: ^1.0.7
Then import the core package:
import 'package:stateful_data/stateful_data.dart';
For Flutter UI helpers:
import 'package:stateful_data/stateful_data_flutter.dart';
StatefulData is small, but the idea behind it is bigger: make every meaningful async outcome explicit, then let the compiler and the UI work with that truth.
Links:
Top comments (0)