DEV Community

Jaroslaw Butajlo
Jaroslaw Butajlo

Posted on

Effortless Asynchronous Operations in Flutter: Unveiling async_cubits

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));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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 async Stream 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?

  1. To create FutureCubit you just need to implement its future method.
class UserDataCubit extends FutureCubit<User> {
  final UserRepository _repository;

  UserDataCubit(this._repository);

  @override
  Future<User> future() => _userRepository.getUser();
}
Enter fullscreen mode Exit fullscreen mode
  1. To fetch the data for the first time, use load method
cubit.load();
Enter fullscreen mode Exit fullscreen mode

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(),
)
Enter fullscreen mode Exit fullscreen mode
  1. To react to state changes of the FutureCubit UI, you can use when method on cubit.state which type will be AsyncValue<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),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. To refresh the data use refresh() method
ElevatedButton(
  onPressed: () => cubit.refresh(),
  child: Text('Refresh'),
)
Enter fullscreen mode Exit fullscreen mode

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.

  1. 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);
}
Enter fullscreen mode Exit fullscreen mode

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(),
)
Enter fullscreen mode Exit fullscreen mode

This will recreate the whole widget tree when the userId is updated. This may cause some performance issues.

  1. Use FutureWithArgsCubit
class UserDataCubit extends FutureWithArgsCubit<String, User> {
  final UserRepository _repository;

  UserDataCubit(this._repository);

  @override
  Future<User> future(String userId) => _repository.getUserById(userId);
}
Enter fullscreen mode Exit fullscreen mode

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(),
)
Enter fullscreen mode Exit fullscreen mode

and to refresh with new arguments use:

cubit.refresh(newUserId);
Enter fullscreen mode Exit fullscreen mode

How to use StreamCubit?

  1. Implement dataStream method
class NewMessageCubit extends StreamCubit<Message> {
  final MessageRepository _messageRepository;

  GetMessagesCubit(this._messageRepository);

  @override
  Stream<Message> dataStream() => _messageRepository.newMessageStream();
}
Enter fullscreen mode Exit fullscreen mode
  1. Subscribe
BlocProvider(
  create: (context) => NewMessageCubit(
    context.read<MessageRepository>(),
  )..subscribe(),
  child: NewMessageWidget(),
)
Enter fullscreen mode Exit fullscreen mode
  1. StreamCubit emits AsyncValue, so listening to state changes is similar to that in FutureCubit
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),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)