provider_kit 0.3.0
provider_kit: ^0.3.0 copied to clipboard
A toolkit for the Provider package with reusable state management components, builders, listeners, mutations, caching, and observers to reduce boilerplate.
provider_kit is a toolkit for Flutter that works seamlessly alongside the provider package. While provider handles dependency injection and makes objects available throughout the widget tree, ProviderKit adds reusable building blocksβnotifiers, state objects, widgets, mutations, caching, observation, and utilitiesβto simplify common development patterns.
Instead of repeatedly implementing state-management logic around ChangeNotifier, ProviderKit gives you ready-to-use components that reduce boilerplate, save time, and keep your code cleaner and more consistent.
| π― Feature | π Description |
|---|---|
| Less Boilerplate | Reusable components for common development patterns, reducing repetitive code. |
| Enhanced Notifiers | Provides specialized notifiers for managing state, async operations, and application logic. |
| Builders & Listeners | Widgets that react to state changes and simplify UI updates and side effects. |
| Multi-State Support | Combine and react to multiple provider states with a single widget. |
| Async State Handling | Handles loading, error, empty, and data states for asynchronous operations. |
| Mutations | Provides dedicated mutation handling for executing asynchronous operations and reacting to their loading, success, and error states. |
| State Caching | Mixins for storing and restoring state when needed. |
| Provider Observation | Observe provider lifecycle and state changes for better visibility and debugging. |
| Immutable State | Provides immutable state objects for predictable state handling. |
| VS Code Snippets | ProviderKit Snippets provides ready-to-use Dart snippets for common ProviderKit boilerplate. |
Contents #
- Getting started
- State
- View State
- Mutations
- Nested State Listener
- Notifier Observer
- VS Code Extension
Getting started #
Add them to your pubspec.yaml file
dependencies:
provider_kit: ^0.3.0
provider: ^6.1.5 # For dependency injection
Using ProviderKit with provider #
ProviderKit works with the provider package for dependency injection and accessing providers from the widget tree. This integration is optional, and we will explore it more later.
If you register your provider in the widget tree, ProviderKit UI widgets can access it internally:
//Registering provider
ChangeNotifierProvider(
create: (_) => MyProvider(),
child: ...,
)
For more information and details about registering your provider, see the documentation of provider package.
Alright, now let's dive in!
State #
ProviderKit's state management is based on Flutter's ChangeNotifier and Listenable ecosystem. Its notifiers provide additional functionality for managing state while remaining compatible with the existing provider ecosystem.
StateNotifier #
StateNotifier is the core notifier provided by this library, similar to Flutter's ValueNotifier but with enhanced capabilities. By extending StateNotifier, our providers become observable, allowing widgets to listen and react to state changes.
class MyProvider extends StateNotifier<int> {
MyProvider() : super(0);
void increment() => state++;
void decrement() => state--;
}
State Widgets #
State Widgets help you react to state changes from your provider (e.g., StateNotifier) in the UI.
The following widgets are available:
StateListenerβ listen to state changes.StateBuilderβ rebuild the UI based on state changes.StateConsumerβ combine listening and rebuilding.
Each widget supports two ways to access the provider:
- Explicitly β pass a provider instance through the
providerparameter. - From context β use the static
.ofmethod to resolve the provider from the widget tree.
Note: For the
.ofmethod to work, the provider must be registered in the widget tree usingProvider,ChangeNotifierProvider, or a similar widget from theproviderpackage.
StateListener #
A widget that listens for state changes and executes side effects without rebuilding the UI.
// Explicit provider
StateListener<MyDataType>(
provider: provider,
listenWhen: (previous, current) => previous != current, // Default, optional
callListenerOnInit: false, // Default, optional
listener: (context, state) {
// Can execute side effects here
},
child: YourWidget(),
);
// Provider from context
StateListener.of<MyProvider, MyDataType>(
listener: (context, state) { /* side effects */ },
child: YourWidget(),
);
StateBuilder #
A widget that rebuilds when the state changes.
// Explicit provider
StateBuilder<MyDataType>(
provider: provider,
rebuildWhen: (previous, current) => previous != current, // Default, optional
builder: (context, state, child) {
return Text('Count: $state');
},
child: YourStaticWidget(), // Optional, won't be rebuilt
);
// Provider from context
StateBuilder.of<MyProvider, MyDataType>(
builder: (context, state, child) => Text('$state'),
);
StateConsumer #
A widget that combines the features of both StateListener and StateBuilder.
// Explicit provider
StateConsumer<MyDataType>(
provider: provider,
listenWhen: (previous, current) => previous != current, // Default, optional
callListenerOnInit: false, // Default, optional
listener: (context, state) {
// Can execute side effects here
},
rebuildWhen: (previous, current) => previous != current, // Default, optional
builder: (context, state, child) {
return Text('Count: $state');
},
child: YourStaticWidget(), // Optional, won't be rebuilt
);
// Provider from context
StateConsumer.of<MyProvider, MyDataType>(
listener: (context, state) { /* side effects */ },
builder: (context, state, child) => Text('$state'),
);
Multi State Widgets #
With Multi State Widgets, we can listen to the states of multiple providers using a single widget. However, these widgets won't try to read the provider.
Note: The providers' states can be of the same type or different types (
dynamic).
The providers themselves are not limited toStateNotifier; any object implementingStateValueListenablecan be used.
- Multi State Widgets include
MultiStateListener,MultiStateBuilderandMultiStateConsumer.
MultiStateListener #
A widget that listens to the state of multiple providers, and a state change in any of the providers will trigger the listener callback.
MultiStateListener<MyDataType>(
providers: [provider1, provider2, provider3],
listenWhen: (previous, current) => previous != current, // Default, optional
callListenerOnInit: false, // Default, optional
listener: (context, states) {
// Can execute side effects here
},
child: YourWidget(),
);
MultiStateBuilder #
A widget that listens to the state of multiple providers, and a state change in any of the providers will trigger the builder.
MultiStateBuilder<MyDataType>(
providers: [provider1, provider2, provider3],
rebuildWhen: (previous, current) => previous != current, // Default, optional
builder: (context, states, child) => Text(states.toString()),
child: YourStaticWidget(), // Optional, won't be rebuilt
);
MultiStateConsumer #
A widget that combines both the features of MultiStateListener and MultiStateBuilder.
MultiStateConsumer<MyDataType>(
providers: [provider1, provider2, provider3],
listenWhen: (previous, current) => previous != current, // Default, optional
callListenerOnInit: false, // Default, optional
listener: (context, states) {
// Can execute side effects here
},
rebuildWhen: (previous, current) => previous != current, // Default, optional
builder: (context, states, child) {
return Text('Count: $states');
},
child: YourStaticWidget(), // Optional, won't be rebuilt
);
Note:
State WidgetsandMulti State Widgetsare not limited toStateNotifier. They can be used with any notifier from this package, as long as it implementsStateValueListenable.
ViewState #
ViewState represents the different states a view can have, including Initial, Loading, Data, Empty, and Error.
It is particularly useful for managing data displayed by a view, such as data loaded from a server or local storage, where the UI needs to represent different stages of the data lifecycle.
| State | Description | Properties |
|---|---|---|
InitialState |
Represents the initial state of a view. | None |
LoadingState |
Represents a loading state with optional progress and message. | message: String?, progress: double? |
DataState |
Represents a successful data state containing the result object. | dataObject: T |
EmptyState |
Represents an empty state with an optional message. | message: String? |
ErrorState |
Represents an error state with an optional message and retry callback. | message: String?, onRetry: VoidCallback?, exception: dynamic, stackTrace: StackTrace? |
Important Note:
EmptyStatewill be used only forIterabledata types. For Example when your T is aList,Setetc.
ViewStateNotifier #
ViewStateNotifier is a StateNotifier that manages ViewState<T>. It simplifies state management by handling various states such as loading, empty, data, and error for a given data type.
By default the initial state of
ViewStateNotifieris LoadingState.
class MyViewStateProvider extends ViewStateNotifier<List<Item>> {
final Repository _repo = Repository();
MyViewStateProvider() : super(const InitialState()) {
init();
}
Future<void> init() async {
try {
state = const LoadingState();
final List<Item> items = await _repo.getItems(10);
if (!mounted) return; // Guard against disposal
if (items.isEmpty) {
state = const EmptyState();
return;
}
state = DataState(items);
} catch (e, s) {
state = ErrorState(e.toString(), e, s, onRefresh);
}
}
void onRefresh() {
state = const LoadingState();
init();
}
}
Note: Use
mountedto check whether the notifier is still alive before updating state after asynchronous operations. This prevents "used after disposed" errors.
Tired of manually implementing the same logic for every provider? No worries! Introducing AsyncViewStateNotifierβa more efficient way to manage our view state.
AsyncViewStateNotifier #
With AsyncViewStateNotifier, much of the boilerplate required for asynchronous state handling is handled automatically:
| Before | After |
|---|---|
|
|
|
AsyncViewStateNotifier automates state management, eliminating the need to repeatedly extend ViewStateNotifier and implement the same boilerplate logic. It streamlines fetching, handling empty states, error management, and retry mechanisms.
By default the initial state of
AsyncViewStateNotifieris LoadingState.
How does it work? #
Instead of writing the entire MyViewStateProvider that we saw above, we can simply extend AsyncViewStateNotifier like this:
class MyViewStateProvider extends AsyncViewStateNotifier<List<Item>> {
@override
FutureOr<List<Item>> fetchData() => Repository().getItems(10);
}
That's it! π
What does AsyncViewStateNotifier handle for us? #
β
Automatically fetches data upon initialization.
β
Transitions to LoadingState before fetching.
β
If the data is Iterable and if it's empty, it switches to EmptyState.
β
Catches exceptions and converts them into ErrorState.
β
Includes a built-in onRefresh function, which rebuilds the initialization logic.
β
Passes the onRefresh function, exception, and stack trace to ErrorState.
β
Internally guarded with mounted β For safe async state updates.
Note
FlutterErrorexceptions are reβthrown and not converted toErrorState. This ensures that fatal programming errors (e.g., assertion failures) are not masked by the UI.
With AsyncViewStateNotifier, state management becomes cleaner, more efficient, and hassle-free.
| Attributes | Type | Description |
|---|---|---|
| Constructor Params | ||
initialState |
ViewState<T> |
The initial state of the provider. Defaults to LoadingState. |
disableEmptyState |
bool |
By default, if T is an Iterable (like List, Set, etc.), an empty iterable will result in EmptyState. Setting this to true forces an empty iterable to be assigned as DataState. |
| Property | ||
state |
ViewState<T> |
The current state of the provider, which can be LoadingState, DataState, EmptyState, or ErrorState. |
| Methods | ||
init() |
FutureOr<void> |
Runs on initialization, setting up states and Guarded with try-catch block. It won't execute again if already initialized unless refresh is called. |
fetchData() |
FutureOr<T> |
Fetches data from an API or database. Must be implemented in subclasses. |
errorStateObject() |
ErrorState<T> |
Helps to customize the default ErrorState Object |
loadingStateObject() |
LoadingState<T> |
Helps to customize the default LoadingState Object |
emptyStateObject() |
EmptyState<T> |
Helps to customize the default EmptyState Object instance. |
refresh() |
Future<void> |
Refreshes the provider which will call init with fetchData() again. |
Lets customize our MyViewStateProvider to the fullest.
class MyViewStateProvider extends AsyncViewStateNotifier<List<Item>> {
// by default `initialState` is `LoadingState`.
// by default `disableEmptyState` is false.
MyViewStateProvider()
: super(initialState: const InitialState(),
//disabling empty state will set the state to `DataState` instead of `EmptyState`
disableEmptyState: true);
@override
FutureOr<void> init() async {
// `init` is internally guarded
// Custom initialization logic goes here
state = const LoadingState();
List<Item> items = await fetchData();
if (!mounted) return; // Guard against disposal
// Additional processing, such as filtering, can be done here
state = DataState(items);
}
@override
FutureOr<List<Item>> fetchData() async {
// Fetch data from an API or database
return [];
}
/// **Custom error state handling**
@override
ErrorState<List<Item>> errorStateObject(Object error, StackTrace stackTrace) {
String message = "Something went wrong";
// Custom error message handling
if (error is MyException) {
message = error.message;
}
return ErrorState<List<Item>>(message, error, stackTrace, refresh);
}
/// **Custom loading state**
@override
LoadingState<List<Item>> loadingStateObject() {
return const LoadingState<List<Item>>('Data is Loading...');
}
/// **Custom empty state**
@override
EmptyState<List<Item>> emptyStateObject() {
return const EmptyState<List<Item>>('No data available.');
}
/// **Optional refresh override**
@override
Future<void> refresh() async {
// Perform any additional refresh logic if needed
super.refresh();
}
}
Note: Even if
refreshis not passed inside theErrorStateforretrymechanism, therefreshwill be automatically be read by theView State Widgetsas long as the provider extendsAsyncViewStateNotifier.
Before moving on to the widgets that listen to ViewStateNotifier and AsyncViewStateNotifier, let's first look at ViewStateWidgetsProvider, which allows us to define the default widgets used to represent different ViewStates.
ViewStateWidgetsProvider #
In a typical application, most screens fetch data from a server or local storage. On every view screen, we compare the state and display the appropriate widget based on that state. For example:
LoadingWidgetwhen the state is loadingErrorWidgetwhen the state is errorEmptyWidgetwhen the data list is emptyDataWidgetwhen the data is successfully fetched
Instead of checking the state type and passing the respective widgets for every single screen, we can reuse the same widgets across all screens. We can streamline this process by wrapping our MaterialApp with ViewStateWidgetsProvider and supplying custom widgets for each state.
Note: These widgets will be used internally by
ViewStateBuilder,ViewStateConsumer,MultiViewStateBuilderandMultiViewStateConsumerwhich weβll explore soon below.
ViewStateWidgetsProvider is simply an inherited widget that provides consistent state based widgets across our app.
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return ViewStateWidgetsProvider(
//supply your initial state widget
initialStateBuilder: (isSliver) {
const widget = Center(child: Text("Initial State"));
return isSliver ? const SliverToBoxAdapter(child: widget) : widget;
},
//supply your empty state widget
emptyStateBuilder: (message, isSliver) {
Widget widget = Center(child: Text(message ?? "No Data Available"));
return isSliver ? SliverToBoxAdapter(child: widget) : widget;
},
//supply your error state widget
//onRetry will refresh the provider
errorStateBuilder: (errorMessage, onRetry, exception, stackTrace, isSliver) {
final widget = Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(errorMessage ?? "An error occurred",
style: const TextStyle(color: Colors.red)),
TextButton(
onPressed: onRetry, child: const Text("Retry")),
],
),
);
return isSliver ? const SliverToBoxAdapter(child: widget) : widget;
},
//supply your loading state widget
loadingStateBuilder: (message, progress, isSliver) {
const widget = Center(child: CircularProgressIndicator());
return isSliver ? const SliverToBoxAdapter(child: widget) : widget;
},
child: const MaterialApp(
//..
),
);
}
}
Additionally, you can wrap any section of your widget tree with ViewStateWidgetsProvider to completely redefine its state widgets, or use ViewStateWidgetsProvider.override to update only specific state builders while inheriting the rest from the parent ViewStateWidgetsProvider.
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context) {
return ViewStateWidgetsProvider.override(
context: context,
// Overrides ONLY the loading builder for this subtree, used internally by `ViewStateWidgets`.
loadingStateBuilder: (message, progress, isSliver) {
const widget = Center(child: ProfileSkeletonLoader());
return isSliver ? const SliverToBoxAdapter(child: widget) : widget;
},
child: const ProfileView(),
);
}
}
Note: In
errorStateBuilder, theerrorMessage,onRetry,exception, andstackTraceare automatically passed to the function if your provider isproviderKit.
With ViewStateWidgetsProvider, we can significantly reduce the amount of UI boilerplate:
| Before | After |
|---|---|
|
|
|
View State Widgets #
These widgets are similar to State Widgets but are designed to adapt based on the corresponding ViewState. They listen to a provider that extends either ViewStateNotifier or AsyncViewStateNotifier, ensuring they respond dynamically to state changes. For example MyViewStateProvider which we learned above.
Each widget offers two ways to access the provider:
- Explicitly β pass a provider instance directly via the
providerparameter. - From context β use the static
.ofmethod.
Note: For the
.ofmethod to work, the provider must be registered in the widget tree usingProvider,ChangeNotifierProvider, or a similar widget from theproviderpackage.
- View State Widgets include
ViewStateListener,ViewStateBuilder,ViewStateConsumer.
ViewStateListener #
This widget provides individual listener callbacks for each ViewState, allowing customized behavior based on the current state.
// Explicit provider
ViewStateListener<MyDataType>(
provider: myProvider,
dataStateListener: (data) => context.showToast(data.toString()),
child: YourWidget(),
)
// Provider from context
ViewStateListener.of<MyViewStateProvider, MyDataType>(
dataStateListener: (data) => context.showToast(data.toString()),
child: YourWidget(),
);
| Attribute Name | Type | Required/Optional | Description |
|---|---|---|---|
provider |
P |
Required | The provider instance to listen to. To resolve the provider from the widget tree, use the .of method instead. |
initialStateListener |
void Function()? |
Optional | Invoked when the state is InitialState. |
loadingStateListener |
void Function(String? message, double? progress)? |
Optional | Invoked when the state is LoadingState. |
dataStateListener |
void Function(T data)? |
Required | Invoked when the state is DataState. |
emptyStateListener |
void Function(String? message)? |
Optional | Invoked when the state is EmptyState. |
errorStateListener |
void Function(String? message, VoidCallback? onRetry, dynamic exception, StackTrace? stackTrace)? |
Optional | Invoked when the state is ErrorState. |
listenWhen |
bool Function(ViewState<T> previous, ViewState<T> next)? |
Optional | Determines whether to listen for state changes based on previous and next state comparisons. |
callListenerOnInit |
bool |
Optional | Determines whether the state listener should be called immediately upon initialization. Defaults to false. |
child |
Widget? |
Required | The child widget wrapped by ViewStateListener. |
Each callback is triggered based on the current ViewState, allowing dynamic response handling within ViewStateListener.
ViewStateBuilder #
This widget provides individual builder for each ViewState, allowing customized behavior based on the current state.
Important Note:
initialStateBuilder,loadingStateBuilder,emptyStateBuilderanderrorStateBuilderthat we supplied toViewStateWidgetsProviderwill be used by this widget internally by default.
// Explicit provider
ViewStateBuilder<MyDataType>(
provider: myProvider,
// Other ViewState builders will be assigned from the `ViewStateWidgetsProvider`.
// We can override them here in `ViewStateBuilder` if needed.
// loadingBuilder: (message, progress, isSliver) => ,
dataBuilder: (data) => Text(data.toString()),
)
// Provider from context
ViewStateBuilder.of<MyViewStateProvider, MyDataType>(
dataBuilder: (data) => Text(data.toString()),
);
The ViewStateBuilder allows customization of UI rendering for different ViewStates, enabling dynamic UI updates based on the current state.
| Attribute Name | Type | Required/Optional | Description |
|---|---|---|---|
provider |
P |
Required | The provider instance to listen to. To resolve the provider from the widget tree, use the .of method instead. |
rebuildWhen |
bool Function(ViewState<T> previous, ViewState<T> next)? |
Optional | Determines if the builder should rebuild based on state changes. |
initialBuilder |
Widget Function(bool isSliver)? |
Optional | Called when the state is InitialState. |
dataBuilder |
Widget Function(T data) |
Required | Called when the state is DataState, passing the retrieved data. |
errorBuilder |
Widget Function(String? message, VoidCallback? onRetry, dynamic exception, StackTrace? stackTrace, bool isSliver)? |
Optional | Called when the state is ErrorState. |
loadingBuilder |
Widget Function(String? message, double? progress, bool isSliver)? |
Optional | Called when the state is LoadingState. |
emptyBuilder |
Widget Function(String? message, bool isSliver)? |
Optional | Called when the state is EmptyState. |
isSliver |
bool |
Optional | Specifies whether the widget is a sliver. Defaults to false. |
child |
Widget? |
Optional | A static child widget that does not depend on the state. |
ViewStateConsumer #
This widget combines features of both ViewStateListener and ViewStateBuilder. We can use this widget when we need both listeners and builders functionality.
Important Note:
initialStateBuilder,loadingStateBuilder,emptyStateBuilderanderrorStateBuilderthat we supplied toViewStateWidgetsProviderwill be used by this widget internally by default.
// Explicit provider
ViewStateConsumer<MyDataType>(
provider: myProvider,
dataStateListener: (data) {
print(data);
},
dataBuilder: (data) => Text(data.toString()),
)
// Provider from context
ViewStateConsumer.of<MyViewStateProvider, MyDataType>(
dataStateListener: (data) => print(data),
dataBuilder: (data) => Text(data.toString()),
);
| Attribute Name | Type | Required/Optional | Description |
|---|---|---|---|
provider |
P |
Required | The provider instance to listen to. To resolve the provider from the widget tree, use the .of method instead. |
initialStateListener |
void Function()? |
Optional | Invoked when the state is InitialState. |
loadingStateListener |
void Function(String? message, double? progress)? |
Optional | Invoked when the state is LoadingState. |
dataStateListener |
void Function(T data)? |
Optional | Invoked when the state is DataState. |
emptyStateListener |
void Function(String? message)? |
Optional | Invoked when the state is EmptyState. |
errorStateListener |
void Function(String? message, VoidCallback? onRetry, dynamic exception, StackTrace? stackTrace)? |
Optional | Invoked when the state is ErrorState. |
listenWhen |
bool Function(ViewState<T> previous, ViewState<T> next)? |
Optional | Determines whether to listen for state changes based on previous and next state comparisons. |
rebuildWhen |
bool Function(ViewState<T> previous, ViewState<T> next)? |
Optional | Determines if the builder should rebuild based on state changes. |
initialBuilder |
Widget Function(bool isSliver)? |
Optional | Called when the state is InitialState. |
loadingBuilder |
Widget Function(String? message, double? progress, bool isSliver)? |
Optional | Called when the state is LoadingState. |
emptyBuilder |
Widget Function(String? message, bool isSliver)? |
Optional | Called when the state is EmptyState. |
dataBuilder |
Widget Function(T data) |
Required | Called when the state is DataState, passing the retrieved data. |
errorBuilder |
Widget Function(String? message, VoidCallback? onRetry, dynamic exception, StackTrace? stackTrace, bool isSliver)? |
Optional | Called when the state is ErrorState. |
isSliver |
bool |
Optional | Specifies whether the widget is a sliver. Defaults to false. |
Multi View State Widgets #
Multi View State Widgets allow us to listen to multiple providers ViewState's with a single widget. However, these widgets do not read the provider.
Note: Our providers states can either be of the same types or dynamic.
Key Difference: Unlike
ViewStateListener,ViewStateBuilder, andViewStateConsumer, Multi View State Widgets require a list of providers as a mandatory attribute.
- Multi View State Widgets include
MultiViewStateListener,MultiViewStateBuilderandMultiViewStateConsumer.
How Multi View State Widgets Work #
The behavior of MultiViewStateBuilder, MultiViewStateListener, and MultiViewStateConsumer depends on the collective states of the provided ViewStates. The highest-priority state in the list determines which builder or listener is triggered.
Priority Order of States #
1οΈβ£ ErrorState (Highest Priority)
- If any provider is in
ErrorState, theerrorStateListener(orerrorBuilder) will be invoked. -
The first encountered
ErrorStatedata will be passed to theerrorStatelistenerorerrorBuilder.
2οΈβ£ InitialState
- If no
ErrorStateis found, but at least one provider is inInitialState, theinitialStateListener(orinitialBuilder) will be invoked.
3οΈβ£ LoadingState
- If no
ErrorStateorInitialStateexists, but at least one provider is inLoadingState, theloadingStateListener(orloadingBuilder) will be invoked. -
First encountered
LoadingStatemessage will be passed to theloadingStatelistenerorloadingBuilder. -
progresswill be aggregated from allLoadingStates into a single combined value.
4οΈβ£ EmptyState
- If none of the above states are present, but at least one provider is in
EmptyState, theemptyStateListener(oremptyBuilder) will be invoked. -
The first encountered
EmptyStatemessage will be passed to theemptyStatelisteneroremptybuilder.
5οΈβ£ DataState<DataType> (Lowest Priority)
- Only If all providers are in
DataState, thedataStateListener(ordataBuilder) will be invoked.
Additional Notes #
- First encountered state applies to all states except
DataState. LoadingStateprogress is aggregated from all activeLoadingStates into a single combined value.- Modifying
listenWhenorrebuildWhenoverrides the default priority logic which will result in triggeringlistenerorbuilderwhenever any provider's state changes.
Handling EmptyState in MultiViewState Widgets #
If some providers have data while others return empty, triggering
EmptyStatemay not be ideal.
Solution: Avoid using EmptyState in the provider logic. Instead, handle empty cases manually inside dataBuilder.
This ensures EmptyState wonβt be triggered unless all providers return an empty state.
MultiViewStateListener #
The MultiViewStateListener allows listening to multiple ViewState providers simultaneously. It merges their states into a unified ViewState, enabling centralized state management without manually handling multiple providers.
Check How Multi View State Widgets Work for more detailed information about how which state is triggered
MultiViewStateListener<MyDataType>(
providers: [viewStateProviderOne, viewStateProviderTwo, viewStateProviderThree],
dataStateListener: (dataStates) {
print(dataStates);
},
child: YourChild(),
);
MultiViewStateListener uses the same parameters as ViewStateListener, but accepts a providers list and does not provide an .of method.
MultiViewStateBuilder #
The MultiViewStateBuilder enables building UI based on multiple ViewState providers simultaneously. It merges their states into a unified ViewState.
Important Note:
initialStateBuilder,loadingStateBuilder,emptyStateBuilderanderrorStateBuilderthat we supplied toViewStateWidgetsProviderwill be used by this widget internally by default.
MultiViewStateBuilder<MyDataType>(
providers: [viewStateProviderOne, viewStateProviderTwo, viewStateProviderThree],
dataBuilder: (dataStates) {
return YourWidget(dataStates);
},
);
MultiViewStateBuilder uses the same parameters as ViewStateBuilder, but accepts a providers list and does not provide an .of method.
MultiViewStateConsumer #
Combines the features of MultiViewStateListener and MultiViewStateBuilder in a single widget.
Important Note:
initialStateBuilder,loadingStateBuilder,emptyStateBuilderanderrorStateBuilderthat we supplied toViewStateWidgetsProviderwill be used by this widget internally by default.
MultiViewStateConsumer<MyDataType>(
providers: [viewStateProviderOne, viewStateProviderTwo, viewStateProviderThree],
dataStateListener: (dataStates) {
print(dataStates);
},
dataBuilder: (dataStates) {
return YourWidget(dataStates);
},
);
MultiViewStateConsumer uses the same parameters as ViewStateConsumer, but accepts a providers list and does not provide an .of method.
Cache Mixins #
Some mixins to help with ViewState caching and data caching that will come handy.
ExViewStateCacheMixin #
This mixin can be used on a provider with ViewState support like ViewStateNotifier or AsyncViewStateNotifier. It provides caching capabilities for different view states. It keeps track of the most recent state of each type and allows easy retrieval of cached states.
Features
- Stores the last known state for each
ViewStatetype. - Allows accessing cached states via getter methods.
- Clears cached states when disposed to free up memory.
class MyViewStateProvider extends ViewStateNotifier<MyDataType> with ExViewStateCacheMixin {
// Your implementation here
}
| Name | Type | Description |
|---|---|---|
exInitialState |
InitialState<T>? |
Stores the last InitialState. |
exLoadingState |
LoadingState<T>? |
Stores the last LoadingState. |
exEmptyState |
EmptyState<T>? |
Stores the last EmptyState. |
exErrorState |
ErrorState<T>? |
Stores the last ErrorState. |
exDataState |
DataState<T>? |
Stores the last DataState. |
exDataStateObject |
T? |
Stores the last known data object from DataState. |
clearCache() |
void |
Clears all cached states. |
DataStateCopyCacheMixin #
This mixin can be used on provider with ViewState support like ViewStateNotifier or AsyncViewStateNotifier. We can use this mixin to cache original data.
sometimes we do local filtering on data we fetched from server and when user cancel filter we need to show the original data back which is exactly when we should use this mixin.
Features:
- Stores the latest
DataState<T>and data whensaveDataStateCopyis called. - Provides access to the cached
DataState<T>and its data object. - Allows clearing cached state manually using
clearDataStateCopy.
class MyViewStateProvider extends AsyncViewStateNotifier<List<String>> with DataStateCopyCacheMixin {
void updateDataState(List<String> newData) {
final newState = DataState(newData);
saveDataStateCopy(newState);
state = newState;
}
void clearFilter(){
state = dataStateCopy!;
}
}
| Name | Type | Description |
|---|---|---|
dataStateCopy |
DataState<T>? |
gets the copy of the saved DataState<T>. |
dataObjectCopy |
T? |
gets the copy of the saved data object from DataState<T>. |
saveDataStateCopy |
(ViewState<T>? newDataState) |
Stores the given DataState<T> and its associated data. |
clearDataStateCopy |
void |
Clears the stored DataState<T> and its associated data. |
Mutations #
A Mutation manages the state of an asynchronous operation such as creating, updating, deleting, or submitting data.
When an operation is running, the UI may need to show a loading indicator, display the result when it succeeds, or show an error when it fails.
Mutation handles these states for you, making it simple for the UI to react to the progress and result of an operation.
A mutation progresses through four states:
MutationIdle β MutationLoading β MutationSuccess or MutationError
Defining a Mutation #
Create a mutation with the generic type representing the return type of the operation:
// Tracks the state of an operation that returns a Todo.
final addTodo = Mutation<Todo>();
Note: Typically, a mutation is kept inside a provider/notifier/controller/view model that owns the operation.
Listening to a Mutation #
Once a mutation is defined, you can listen to its state in the UI using ProviderKit state widgets such as StateBuilder, StateListener, and StateConsumer.
StateBuilder(
provider: deleteTodo,
builder: (context, state, child) {
return state.when(
idle: () => const Text('Delete'),
loading: () => const CircularProgressIndicator(),
success: (_) => const Icon(Icons.check),
error: (error, stackTrace) => const Icon(Icons.error),
);
},
);
Note: You can perform side effects for mutations with
StateListener
Triggering a Mutation #
Once a mutation is defined and being observed, execute it by passing an asynchronous operation to run():
await addTodo.run(
() => Api.addTodo(todo),
);
This is commonly triggered by a user interaction:
ElevatedButton(
onPressed: () async {
await addTodo.run(
() => Api.addTodo(todo),
);
},
child: const Text('Add Todo'),
);
When the operation starts, the mutation enters MutationLoading.
When the operation completes:
- If the operation succeeds, the mutation enters
MutationSuccess. - If the operation throws an exception, the mutation enters
MutationError.
The successful result is available through MutationSuccess, while MutationError contains the original error and its stack trace.
Note: Mutations allow multiple
run()calls to execute concurrently. Only the most recently started execution can update the mutation state. Earlier executions still complete normally but cannot overwrite a newer state or a state set byreset().
Using the Result #
run() returns the result produced by the asynchronous operation, so you can store it in a variable and use it for subsequent application logic:
final todo = await addTodo.run(
() => Api.addTodo(todoId),
);
// Add the created todo to the local list.
myList = [...myList, todo]
The result is also available through the mutation's data property after a successful execution:
if (addTodo.isSuccess) {
final todo = addTodo.data;
// Use the result for other application logic.
}
Use the returned value from run() when you need the result immediately after the operation. Use data when you want to access the result from the current successful mutation state.
Resetting #
Once an operation is completed, you can reset the mutation back to MutationIdle by calling reset() if needed.
addTodo.reset();
This clears the current success or error state, returns the mutation to its idle state, and invalidates any in-flight execution so that it cannot update the mutation state when it completes.
Disposing #
Dispose a mutation when it is no longer needed, typically when the provider, notifier, controller, or view model that owns it is disposed:
addTodo.dispose();
A disposed mutation should not be used again. The same mutation can be reused for subsequent executions:
See MutationState for state handling and pattern matching.
MutationGroup #
A MutationGroup manages multiple independent Mutation instances using unique keys.
Each key represents one independent instance of the operation. Requesting a key returns the Mutation associated with that key:
final deleteTodo = MutationGroup<void>();
final mutation = deleteTodo(todo.id);
await mutation.run(
() => Api.deleteTodo(todo.id),
);
Conceptually, the group manages:
deleteTodo
βββ todo 1 β Mutation<void>
βββ todo 2 β Mutation<void>
βββ todo 3 β Mutation<void>
βββ ...
Each keyed mutation has completely independent state:
Todo 1 β Loading
Todo 2 β Idle
Todo 3 β Error
The key identifies the mutation within a specific MutationGroup instance. The group owns the cache and lifecycle of all mutations created through it.
This is particularly useful for lists, where the same operation may need to run independently for many items.
MutationGroup also automatically disposes keyed mutations that are no longer needed. This prevents a large or continuously scrolling list from retaining a mutation for every item the user has ever viewed.
Defining a MutationGroup #
Create a MutationGroup with the generic type representing the return type of the operation:
final deleteTodo = MutationGroup<void>();
The group is typically kept inside a provider, controller, view model, or other object that owns the operation:
class TodoProvider {
final deleteTodo = MutationGroup<void>();
Future<void> delete(int id) {
return deleteTodo(id).run(
() => Api.deleteTodo(id),
);
}
void dispose() {
deleteTodo.dispose();
}
}
The group should be disposed when its owner is disposed.
Getting a Mutation by Key #
Call the group with a key to get the mutation associated with that key:
final mutation = deleteTodo(todo.id);
If a mutation for that key is already cached, the existing instance is returned:
final first = deleteTodo(todo.id);
final second = deleteTodo(todo.id);
identical(first, second); // true while cached
Note: The cache belongs to that specific
MutationGroupinstance. A different group, even when called with the same key, has its own independent cache.
This is particularly useful for lists. A list item can be removed from the widget tree when it scrolls off-screen while its mutation remains cached in the group.
When the item appears again, requesting the same key from the same group returns the existing mutation if it is still cached.
Using MutationGroup in a List #
A list item can observe the mutation associated with its own key:
ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
// Returns the existing mutation for this key if it is cached;
// otherwise, creates and caches a new mutation.
final mutation = provider.deleteTodo(todo.id);
return StateBuilder(
provider: mutation,
builder: (context, state, child) {
return ListTile(
title: Text(todo.title),
trailing: IconButton(
onPressed: state.isLoading
? null
: () => provider.delete(todo.id),
icon: state.isLoading
? const CircularProgressIndicator()
: const Icon(Icons.delete),
),
);
},
);
},
);
Automatic Disposal #
Keyed mutations are automatically removed from the group's cache when they have no listeners, based on their current state.
By default:
No listeners + Idle β Eligible for auto-dispose
No listeners + Success β Eligible for auto-dispose
No listeners + Error β Eligible for auto-dispose
No listeners + Loading β Keep alive
Has listeners β Keep alive
This prevents the group from retaining every mutation ever created in memory, which is especially important for large or continuously scrolling lists.
A mutation that is currently loading is always kept alive, even when it has no listeners. This allows the operation to finish without losing its state while the widget is temporarily absent from the widget tree.
Once the loading operation finishes, the mutation becomes eligible for automatic disposal again if it has no listeners.
Keeping Completed States Alive #
By default, successful and failed mutations are automatically disposed when they have no listeners.
You can preserve completed states by passing them to keepAliveStates:
final deleteTodo = MutationGroup<void>(
keepAliveStates: {
KeepAliveState.success,
},
);
Note:
Loadingis always kept alive, regardless ofkeepAliveStates. This ensures that ongoing operations are never cancelled due to automatic disposal.
In this example:
Idle β Eligible for auto-dispose
Loading β Keep alive
Success β Keep alive
Error β Eligible for auto-dispose
To keep both success and error states alive:
final deleteTodo = MutationGroup<void>(
keepAliveStates: {
KeepAliveState.success,
KeepAliveState.error,
},
);
This can be useful when a completed state should remain available after its widget is temporarily removed from the widget tree.
Caution: Be careful when keeping states alive in large or long-lived groups, as cached mutations remain in memory until they are automatically disposed, manually disposed, or the group itself is disposed.
Manual Disposal #
Dispose a single keyed mutation with disposeKey():
deleteTodo.disposeKey(todo.id);
This immediately removes that mutation from the group and disposes it, even if it is currently loading.
To dispose every cached mutation in the group:
deleteTodo.dispose();
This also disposes mutations that are currently loading.
A provider or controller that owns a group should dispose it when the owner is disposed:
class TodoProvider {
final deleteTodo = MutationGroup<void>();
void dispose() {
deleteTodo.dispose();
}
}
Note: Always dispose the
MutationGroupwhen it is no longer needed.
Why Use MutationGroup? #
MutationGroup is useful when the same type of operation needs to maintain independent state for multiple entities.
It provides:
- Independent state β each key has its own
Mutationand state. - Key-based reuse β requesting the same key from the same group returns the existing cached mutation while it remains cached.
- Widget-independent state β the mutation is owned by the group rather than by the widget displaying the item.
- Automatic disposal β unobserved mutations can be removed from the cache automatically, preventing unnecessary memory usage in large lists.
- Configurable retention β completed success or error states can be kept alive when needed.
- Manual control β individual mutations or the entire group can be disposed explicitly.
Mutation vs MutationGroup #
Use Mutation when one operation has one shared state:
final logout = Mutation<void>();
Use MutationGroup when the same operation needs independent state for multiple keys:
final deleteTodo = MutationGroup<void>();
deleteTodo(todo1.id);
deleteTodo(todo2.id);
deleteTodo(todo3.id);
Mutation |
MutationGroup |
|
|---|---|---|
| State instances | One | One per key |
| Best for | One shared operation | Independent operations per item |
| Key required | No | Yes |
| Independent states | No | Yes |
| Automatic disposal | No | Yes |
| Manual disposal | dispose() |
disposeKey() / dispose() |
Note: Use
MutationGroupwhen the operation itself is the same, but each key needs its own independent mutation state and lifecycle.
MutationState #
MutationState represents the different states of a mutation operation, including Idle, Loading, Success, and Error.
It is particularly useful for tracking the progress and result of asynchronous operations such as creating, updating, deleting, submitting, logging in, or uploading data.
| State | Description | Properties |
|---|---|---|
MutationIdle |
Represents the initial state before the mutation has been executed. | None |
MutationLoading |
Represents a mutation that is currently executing. | None |
MutationSuccess |
Represents a successfully completed mutation and contains its result. | data: T |
MutationError |
Represents a failed mutation and contains the error and its stack trace. | error: Object, stackTrace: StackTrace |
MutationState provides pattern-matching helpers for handling its different states. Use when() and maybeWhen() when you want to work with the values exposed by each state, and map() and maybeMap() when you need access to the complete state object.
when #
Use when() when every state should be handled:
state.when(
idle: () => const Text('Ready'),
loading: () => const CircularProgressIndicator(),
success: (data) => Text('Success: $data'),
error: (error, stackTrace) => Text('Error: $error'),
);
maybeWhen #
Use maybeWhen() when only specific states need handling:
state.maybeWhen(
loading: () => const CircularProgressIndicator(),
orElse: () => const SizedBox(),
);
map #
Use map() when you need access to the complete state object:
state.map(
idle: (state) => const Text('Ready'),
loading: (state) => const Text('Loading'),
success: (state) => Text('Result: ${state.data}'),
error: (state) => Text('Error: ${state.error}'),
);
maybeMap() can be used when only specific state objects need to be handled.
state.maybeMap(
loading: (state) => const CircularProgressIndicator(),
success: (state) => Text('Result: ${state.data}'),
orElse: () => const SizedBox(),
);
NestedStateListener #
NestedStateListener is a widget that nests multiple state listeners within a single widget. It allows you to combine different types of listeners and manage them together efficiently.
- Supports nesting multiple state listeners.
- Works seamlessly with
StateListener,ViewStateListener,MultiStateListener, andMultiViewStateListener. - Reduces boilerplate code by combining multiple listeners into a single widget.
NestedStateListener(
listeners: [
StateListener.of<MyProvider,DataType>(
listener: (context, state) {
// Handle state changes
},
),
MultiStateListener<DataType>(
providers: [ProviderOne(),ProviderTwo()],
listener: (context, states) {
// Handle state changes
},
),
ViewStateListener<DataType>(
provider: MyProvider(),
dataStateListener: (data) {
// Handle view state changes
},
),
MultiViewStateListener<DataType>(
providers: [ProviderOne(),ProviderTwo()],
dataStateListener: (states) {
// Handle state changes
},
),
],
child: MyChildWidget(),
);
| Attribute | Type | Description |
|---|---|---|
listeners (Required) |
List<SingleChildWidget> |
A list of listeners to be applied. These can include StateListener, ViewStateListener, MultiStateListener, and MultiViewStateListener. |
child (Required) |
Widget |
The child widget that will be wrapped by the listeners. |
Note: Ensure that the
listenerslist contains at least one listener to avoid an empty nesting.
NotifierObserver #
The NotifierObserver helps you monitor the lifecycle of all notifiers in your application.
It can be used for debugging, logging, analytics, or any other crossβcutting concern β it receives callbacks whenever a notifier is created, changes state, reports an error, or is disposed.
Setting up a global observer #
Assign an implementation of NotifierObserver to the static observer field on NotifierBase.
This is typically done at the start of your app, before running the MaterialApp.
void main() {
// Set the global observer
NotifierBase.observer = MyNotifierObserver();
runApp(const MyApp());
}
class MyNotifierObserver extends NotifierObserver {
@override
void onChange(NotifierBase notifier, Change change) {
super.onChange(notifier, change);
debugPrint(
'notifier onChange -- \${notifier.runtimeType}, '
'\${change.currentState.runtimeType} ---> \${change.nextState.runtimeType}',
);
}
@override
void onCreate(NotifierBase notifier) {
super.onCreate(notifier);
debugPrint('notifier onCreate -- \${notifier.runtimeType}');
}
@override
void onError(
NotifierBase notifier, Object error, StackTrace stackTrace) {
debugPrint(
'notifier onError -- \${notifier.runtimeType} '
'Error: \$error StackTrace: \$stackTrace',
);
super.onError(notifier, error, stackTrace);
}
@override
void onDispose(NotifierBase notifier) {
super.onDispose(notifier);
debugPrint('notifier onDispose -- \${notifier.runtimeType}');
}
}
VS Code Extension #
Speed up ProviderKit development with ProviderKit Snippets, a VS Code extension with ready-to-use Dart snippets for common ProviderKit boilerplate.
Type pk in a Dart file to discover the available snippets.
Install VS Code Extension β ProviderKit Snippets
Acknowledgements #
Some features of this package were inspired by flutter_bloc and riverpod.
π Features & Bug Reports #
Have a feature request or found a bug? Feel free to open an issue on the GitHub Issue Tracker. Your feedback helps improve ProviderKit!
π€ Contributing #
Contributions are welcome! If you'd like to improve ProviderKit, fix a bug, add a feature, or improve the documentation, feel free to open an issue or submit a pull request.
Please make sure your changes are tested and follow the existing project conventions.
π§ͺ Development #
ProviderKit is backed by a comprehensive automated test suite covering widgets, state management, listeners, edge cases, and other core package functionality.
π’ Connect with Me #
Stay updated and reach out for collaborations!
Website: Ram Prasanth