Effortless Asynchronous Operations in Flutter: Unveiling async_cubits
Jaroslaw Butajlo
Posted on January 4, 2024
In the world of Flutter development, managing asynchronous operations can be tricky. Whether it's fetching data from an API or dealing with background tasks, developers often encounter challenges that make their code more complicated or repetitive (copy-pasted). Instead of concentrating on the business logic, we end up writing a lot of boilerplate code to handle the loading, error, and success states of asynchronous operations. Much of that logic can be simplified and represented as reusable code.
Check existing solutions
-
ObservableFuture
,ObservableStream
in MobX -
FutureProvider
,StreamProvider
, etc. in Riverpod
So why another solution?
Motivation
- We wanted to use it in the currently developed app. The app we developed extensively utilized bloc and cubits, so switching the state management solution could potentially introduce new bugs.
- The team was predominantly experienced in utilizing the
bloc
package. - There is no widely recognized, consistent, abstract way of handling asynchronous operations that removes boilerplate logic for managing loading, error, and success states using bloc/cubit.
Research
Our research strategy was simple: don't try to reinvent the wheel and look on what other mature solutions (like Riverpod, MobX, react-query) are doing.
We did like the idea and the behavior of AsyncValue
used in Riverpod. So we decided to take the simplicity of Riverpod's FutureProvider
and StreamProvider
and create similar behavior using cubits from bloc
package.
The problem
The problematic code:
// State
abstract class UserDataState {
const UserDataState();
}
class UserDataLoading extends UserDataState {
const UserDataLoading();
}
class UserDataLoaded extends UserDataState {
final User data;
const UserDataLoaded(this.data);
}
class UserDataError extends UserDataState {
final Object error;
final StackTrace stackTrace;
const UserDataError(this.error, this.stackTrace);
}
// Cubit
class UserDataCubit extends Cubit<UserDataState> {
final UserRepository _repository;
DataCubit(this._repository) : super(const DataLoading());
Future<void> fetchData() async {
emit(DataLoading());
try {
final fetchedData = await _repository.getUser();
emit(DataLoaded(fetchedData));
} catch (e, st) {
emit(DataError(e, st));
}
}
}
As we can see most of the code is not connected to the business logic, but it's a boilerplate code for handling async fetch operation. Even in the small app, this repeated logic will appear several times, so it's better to handle them in a consistent way.
async_cubits
package
The package async_cubits
contains a set of cubits:
-
FutureCubit
- implements logic for handling loading, error, and success states and provides methods for reloading and seamlessly refreshing data. Typically used to fetch data from some external data source (eg. REST API, GraphQL, local database). -
StreamCubit
- implements logic for handling loading, error, and success states for asyncStream
events. Typically used when the app uses reactive data sources (eg. listenening to Firestore's document changes). -
MutationCubit
- implements logic for handling async operations that are invoked by some side effect, eg. when a user taps some button. Typically used to mutate state of the backend.
MutationCubit
will be described in next article.
How to use FutureCubit
?
- To create
FutureCubit
you just need to implement itsfuture
method.
class UserDataCubit extends FutureCubit<User> {
final UserRepository _repository;
UserDataCubit(this._repository);
@override
Future<User> future() => _userRepository.getUser();
}
- To fetch the data for the first time, use
load
method
cubit.load();
In most cases, the data should be fetched when the bloc is created. BlocProvider
can be used for that:
BlocProvider(
create: (context) => UserDataCubit(
context.read<UserRepository>(),
)..load(),
child: UserPageBody(),
)
- To react to state changes of the
FutureCubit
UI, you can usewhen
method oncubit.state
which type will beAsyncValue<User>
.
class UserPageBody extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cubit = context.watch<UserDataCubit>();
final state = cubit.state;
return state.when(
loading: LoadingWidget.new,
error: (error, stackTrace) => ErrorWidget(error),
data: (data) => UserLoadedWidget(data),
);
}
}
- To refresh the data use
refresh()
method
ElevatedButton(
onPressed: () => cubit.refresh(),
child: Text('Refresh'),
)
During refresh, the AsyncValue.when
method by default will use data
callback if the data was previously fetched.
Future with arguments
There are 2 methods of passing arguments to async method.
- Inject arguments through constructor of
FutureCubit
class UserDataCubit extends FutureCubit<User> {
final UserRepository _repository;
final String userId;
UserDataCubit(this._repository, {required this.userId});
@override
Future<User> future() => _repository.getUserById(userId);
}
It works, but there's no way of reloading/refreshing with new arguments through the lifetime of the cubit.
Solution: recreate the cubit when the arguments are changed, eg. using Key
in BlocProvider
.
BlocProvider(
key: ValueKey(userId),
create: (context) => UserDataCubit(
context.read<UserRepository>(),
userId: userId,
)..load(),
child: UserPageBody(),
)
This will recreate the whole widget tree when the userId
is updated. This may cause some performance issues.
- Use
FutureWithArgsCubit
class UserDataCubit extends FutureWithArgsCubit<String, User> {
final UserRepository _repository;
UserDataCubit(this._repository);
@override
Future<User> future(String userId) => _repository.getUserById(userId);
}
This type of cubit enables to pass arguments through load(args)
and refresh(args)
methods.
Example:
BlocProvider(
create: (context) => UserDataCubit(
context.read<UserRepository>(),
)..load(userId),
child: UserPageBody(),
)
and to refresh with new arguments use:
cubit.refresh(newUserId);
How to use StreamCubit
?
- Implement
dataStream
method
class NewMessageCubit extends StreamCubit<Message> {
final MessageRepository _messageRepository;
GetMessagesCubit(this._messageRepository);
@override
Stream<Message> dataStream() => _messageRepository.newMessageStream();
}
- Subscribe
BlocProvider(
create: (context) => NewMessageCubit(
context.read<MessageRepository>(),
)..subscribe(),
child: NewMessageWidget(),
)
-
StreamCubit
emitsAsyncValue
, so listening to state changes is similar to that inFutureCubit
class NewMessageWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cubit = context.watch<NewMessageCubit>();
final state = cubit.state;
return state.when(
loading: LoadingWidget.new,
error: (error, stackTrace) => ErrorWidget(error),
data: (data) => LoadedMessage(data),
);
}
}
See more
As you can observe, the FutureCubit
or StreamCubit
significantly
reduces the amount of code needed to handle states for asynchronous data.
Check out the package on pub.dev and GitHub.
In the next article, I will describe the usage of MutationCubit
, which is also a part of the async_cubits
package.
Posted on January 4, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.