async_cubits
A package that provides a set of cubits for making asynchronous operations in a simpler, consistent way.
Inspiration
This package is inspired by Riverpod's FutureProvider and StreamProvider. The AsyncValue class is mostly the same as in the riverpod package. Please check the great work done there.
Installation 💻
❗ In order to start using Async Cubits you must have the Flutter SDK installed on your machine.
flutter pub add async_cubits
Usage 🚀
Importing the package
import 'package:async_cubits/async_cubits.dart';
Using a FutureCubit
FutureCubit is a cubit for async operations that return a value (e.g. fetching data from an API).
Concrete subclasses define their own load/refresh methods and call performLoad or performRefresh with the appropriate future factory.
Without args
class GetUserCubit extends FutureCubit<User> {
GetUserCubit(this._userRepository);
final UserRepository _userRepository;
Future<void> load() => performLoad(_userRepository.getUser);
Future<void> refresh() => performRefresh(_userRepository.getUser);
}
With args
class GetUserByIdCubit extends FutureCubit<User> {
GetUserByIdCubit(this._userRepository);
final UserRepository _userRepository;
Future<void> load(int id) => performLoad(() => _userRepository.getUserById(id));
Future<void> refresh(int id) => performRefresh(() => _userRepository.getUserById(id));
}
Loading data
Call load() to trigger the async operation. The cubit emits AsyncValue<T> in the following order:
AsyncValue.loading()AsyncValue.data(value)on success, orAsyncValue.error(error, stackTrace)on failure
In your UI, use BlocProvider and BlocBuilder:
BlocProvider(
create: (context) => GetUserCubit(
context.read<UserRepository>(),
)..load(),
child: BlocBuilder<GetUserCubit, AsyncValue<User>>(
builder: (context, state) {
return state.when(
loading: () => const CircularProgressIndicator(),
error: (error, stackTrace) => Text('$error'),
data: (user) => Text(user.name),
);
},
),
);
Refreshing data
Call refresh() to reload without clearing the previously loaded value. The cubit emits:
- A state where
isLoadingistrueandvalueholds the previous data — useful for showing existing content while reloading without a loading indicator. SetskipLoadingOnRefresh: falseinAsyncValue.whento show a loading indicator instead. AsyncValue.data(newValue)on success.- On failure, a state where
isLoadingisfalse,error/stackTraceare set, andvaluestill holds the previous data — useful for showing the previous content alongside an error message (useskipError: trueinAsyncValue.when).
ElevatedButton(
onPressed: () => context.read<GetUserCubit>().refresh(),
child: const Text('Refresh'),
),
Using a StreamCubit
StreamCubit listens to an async stream of values (e.g. a websocket feed).
class NewMessageCubit extends StreamCubit<Message> {
NewMessageCubit(this._messageRepository);
final MessageRepository _messageRepository;
@override
Stream<Message> dataStream() => _messageRepository.newMessageStream();
}
StreamCubit emits AsyncValue<T>, so consuming state is identical to FutureCubit:
class NewMessageWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final state = context.watch<NewMessageCubit>().state;
return state.when(
loading: LoadingWidget.new,
error: (error, stackTrace) => ErrorWidget(error),
data: (message) => LoadedMessage(message),
);
}
}
Using a MutationCubit
MutationCubit handles asynchronous mutations — API calls, database writes, form submissions, etc.
class SaveUserCubit extends MutationCubit<User, void> {
SaveUserCubit(this._userRepository);
final UserRepository _userRepository;
@override
Future<void> mutation(User input) => _userRepository.saveUser(input);
}
Invoke the mutation with invoke:
context.read<SaveUserCubit>().invoke(user);
Invalidating a FutureCubit after a mutation
Pass the same AsyncCubitContainer to both cubits so that after a successful mutation you can call invalidate<T>() to refresh the related FutureCubit. Both cubits default to AsyncCubitContainer.defaultInstance, so for simple apps no extra setup is needed:
class GetUserCubit extends FutureCubit<User> {
GetUserCubit(this._userRepository);
final UserRepository _userRepository;
Future<void> load() => performLoad(_userRepository.getUser);
Future<void> refresh() => performRefresh(_userRepository.getUser);
}
class SaveUserCubit extends MutationCubit<User, void> {
SaveUserCubit(this._userRepository);
final UserRepository _userRepository;
@override
Future<void> mutation(User input) => _userRepository.saveUser(input);
@override
void onSuccess(void result) {
super.onSuccess(result);
invalidate<GetUserCubit>();
}
}
invalidate<T>() invalidates all registered cubits of type T. Use the optional filter parameter to target a specific instance:
invalidate<GetUserByIdCubit>(filter: (cubit) => cubit.id == userId);
For feature modules with isolated lifecycles, create a dedicated container and pass it explicitly:
final container = AsyncCubitContainer();
BlocProvider(create: (_) => GetUserCubit(_repo, container: container)),
BlocProvider(create: (_) => SaveUserCubit(_repo, container: container)),
MutationCubit emits MutationState<T>, a sealed class with four subtypes:
| State | Type | Description |
|---|---|---|
| Idle | MutationIdle |
Initial state — mutation not yet invoked |
| Loading | MutationLoading |
Mutation in progress |
| Success | MutationSuccess |
Mutation completed — holds result |
| Failure | MutationFailure |
Mutation failed — holds error and stackTrace |
Use pattern matching or the is checks (isLoading, isSuccess, isFailure) to react to state changes:
BlocBuilder<SaveUserCubit, MutationState<void>>(
builder: (context, state) {
return switch (state) {
MutationIdle() || MutationSuccess() => SaveButton(),
MutationLoading() => const CircularProgressIndicator(),
MutationFailure(:final error) => Text('Failed: $error'),
};
},
)
Libraries
- async_cubits
- A set of Cubits that helps to handle async operations in simple and consistent way.