Comparing with Bloc
The claim we are going to prove:
Building your apps with AsyncRedux is much easier and faster than building them with Bloc plus Cubit.
The design difference
The code to trigger a state change is similar, because both AsyncRedux and Bloc/Cubit implement an MVI architecture: Your widgets don't know how the state is changed, but only what should change.
For example, to increment a counter by an amount of 1:
// AsyncRedux
dispatch(Increment(amount: 1));
// Bloc/Cubit
counterCubit.increment(amount: 1);
As you can see, AsyncRedux changes state via classes (called "actions"), while Bloc/Cubit just uses regular methods inside your Cubit class. That's the main design difference.
Since classes are made to be extended, and give you a stable identifier (the action type), it allows AsyncRedux to provide a load of features out-of-the-box, that are non-existent or hard to implement with Cubit.
Actions extend your AppAction, and change the state by returning a new state from their reduce() method,
while Cubit methods change the state by calling emit(newState).
Loading and error indicators
To show a loading indicator while an action is in progress,
and to show an error message if it fails,
you can use the isWaiting and isFailed methods provided by AsyncRedux:
// Widget
if (context.isFailed(Increment)) return Text('Error');
else if (context.waiting(Increment)) return CircularProgressIndicator();
else return Text('Counter: ${context.state.counter}');
// State (no need to add loading or error fields to the state)
class AppState {
final int counter;
AppState({required this.counter});
}
// Action
class Increment extends AppAction {
final int amount;
Increment(this.amount);
Future<AppState> reduce() async {
// ... do async work
return state.copy(counter: state.counter + amount);
}
}
While in Bloc/Cubit, you need to implement this logic manually,
by adding isLoading and error fields to your state,
and updating them in your Cubit methods:
// Widget
final state = context.watch<CounterCubit>().state;
if (state.error != null) return Text('Error');
else if (state.isLoading) return CircularProgressIndicator();
else return Text('Counter: ${state.counter}');
// State (needs isLoading and error fields)
class CounterState {
final int counter;
final bool isLoading;
final String? error;
CounterState({required this.counter, required this.isLoading, this.error});
}
// Cubit (needs to set isLoading and error fields)
class CounterCubit extends Cubit<CounterState> {
CounterCubit() : super(CounterState(counter: 0, isLoading: false));
Future<void> increment(int amount) async {
emit(state.copy(isLoading: true, error: null));
try {
// ... do async work
emit(state.copy(counter: state.counter + amount, isLoading: false));
} catch (error) {
emit(state.copy(isLoading: false, error: error.toString()));
}
}
}
In other words, AsyncRedux provides built-in support for loading indicators and error handling, while in Bloc/Cubit you need to implement it yourself by adding fields to your state and updating them in your Cubit methods.
Showing an error dialog
With AsyncRedux, when an action fails you can just throw an exception from its reduce() method.
AsyncRedux allows for a central error handling mechanism that is set up when the
store is created. In special, if an action fails because the user did something wrong,
you can throw a UserException, and AsyncRedux will automatically show an error dialog.
// Action
class Increment extends AppAction {
final int amount;
Increment(this.amount);
AppState? reduce() {
if (amount < 0) throw UserException('Amount cannot be negative'); // Here!
return state.copy(counter: state.counter + amount);
}
}
// Widget (no error handling needed)
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () => dispatch(Increment(amount)),
child: Text('Increment'),
);
}
While in Bloc/Cubit, you need to implement this logic manually by listening for errors in your widget and showing a dialog when an error occurs.
// Cubit
class CounterCubit extends Cubit<CounterState> {
void increment(int amount) async {
try {
if (amount < 0) throw Exception('Amount cannot be negative');
emit(state.copy(counter: state.counter + amount));
} catch (error) {
emit(state.copy(error: error.toString()));
}
}
}
// Widget (must listen for errors and show dialog manually)
Widget build(BuildContext context) {
return BlocListener<CounterCubit, CounterState>(
listener: (context, state) {
if (state.error != null) {
showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text('Error'),
content: Text(state.error!),
),
);
}
},
child: ElevatedButton(
onPressed: () => context.read<CounterCubit>().increment(amount),
child: Text('Increment'),
),
);
}
Note: Bloc/Cubit supports adding an error observer to
Bloc.observer. That can be used to intercept errors globally withonErrorand show a dialog, but you cannot throw errors in your Cubit methods. You have to try/catch errors and useaddError()manually.
Mixins
AsyncRedux provides many built-in mixins that can be added to your actions. For example, suppose you want to check for internet connectivity before executing an action, then prevent re-entrance of the action while it's already running, and also retry the action if it fails due to a network error. You also want to be able to show a loading indicator while the action is in progress, and show an error in the screen if it fails.
To do it with AsyncRedux:
- Check for internet connectivity using the
with CheckInternetmixin. - Prevent re-entrance by using the
with NonReentrantmixin. - Retry the action using the
with Retrymixin. - You don't need to do anything to show a loading indicator, it's automatic.
- Also, no need to do anything to show an error in the screen, it's automatic.
This is what the action looks like:
class LoadPrices extends AppAction with CheckInternet, NonReentrant, Retry {
Future<AppState> reduce() async {
final prices = await repository.fetchPrices();
return state.copy(prices: prices);
}
}
While in Bloc/Cubit, you need to implement all this logic manually:
- Check for internet connectivity using the
connectivity_pluspackage. - Prevent re-entrance by using the
_isRunningboolean flag. - Retry the action using the
retrypackage. - Show a loading indicator by emitting a loading state
PricesLoading. - Handle errors by emitting an error state
PricesError.
This is what the Cubit looks like:
class PricesCubit extends Cubit<PricesState> {
final PricesRepository repository;
bool _isRunning = false;
PricesCubit(this.repository) : super(PricesInitial());
Future<void> load() async {
if (_isRunning) return;
_isRunning = true;
try {
final connectivityResult = await Connectivity().checkConnectivity();
if (connectivityResult == ConnectivityResult.none) {
emit(PricesError('No internet connection'));
return;
}
emit(PricesLoading());
// Using the `retry` package.
final prices = await retry(
() => repository.fetchPrices(),
maxAttempts: 3,
retryIf: (e) => e is SocketException || e is TimeoutException,
);
emit(PricesLoaded(prices));
} catch (e) {
emit(PricesError(e.toString()));
} finally {
_isRunning = false;
}
}
}
Is there any way to extract this logic to make it easier in Bloc/Cubit? Sure. Here's how:
class PricesCubit extends Cubit<PricesState> with CheckInternet, NonReentrant, Retry {
final PricesRepository repository;
PricesCubit(this.repository) : super(PricesInitial());
Future<void> load() async {
await nonReentrant('load', () async {
if (!await hasInternet()) {
emit(PricesError('No internet connection'));
return;
}
emit(PricesLoading());
try {
final prices = await retryWithBackoff(
() => repository.fetchPrices(),
);
emit(PricesLoaded(prices));
} catch (e) {
emit(PricesError(e.toString()));
}
});
}
}
Still, there is no comparison with the AsyncRedux version shown previously. There is no way to add these features to Cubit methods automatically, because they are just regular methods. AsyncRedux actions are classes, so they can be extended with mixins that add features to them.
Bloc transformers
Here's how to add non-reentrant, debounce, throttle and Fresh mixins to an action with AsyncRedux:
// Retry
class LoadPrices extends AppAction with Retry { ... }
// Debounce
class LoadPrices extends AppAction with Debounce { ... }
// Throttle
class LoadPrices extends AppAction with Throttle { ... }
// Fresh
class LoadPrices extends AppAction with Fresh { ... }
In Bloc/Cubit, you need to implement all this logic manually.
However, there is a simpler way to do it, as long as you revert to use pure Bloc (no Cubit)
and then use the transformer parameter. For example:
class SearchBloc extends Bloc<SearchEvent, SearchState> {
SearchBloc() : super(SearchInitial()) {
on<PerformSearch>(
_onPerformSearch,
transformer: droppable(), // Here!
);
}
Future<void> _onPerformSearch(
PerformSearch event,
Emitter<SearchState> emit,
) async {
emit(SearchLoading());
final results = await repo.search(event.query);
emit(SearchLoaded(results));
}
}
Some available transformers:
droppable(): ignore any events added while an event is processingrestartable(): process only the latest event and cancel previous onesconcurrent(): process events concurrentlysequential(): process events sequentiallydebounce(): wait for a pause in events before processing the latest onethrottle(): process the first event and ignore new events for a duration
Unfortunately, these transformers don't work when using Cubit methods, only with pure Bloc events. These are a lot more verbose and complex than both Cubit and AsyncRedux.
Refresh indicators
In AsyncRedux, you can use the dispatchAndWait() method to show a refresh indicator while an action is in progress:
return RefreshIndicator(
onRefresh: dispatchAndWait(DownloadStuffAction());
child: ListView(...),
In Bloc/Cubit, you need to implement this logic manually by adding a loading state to your Cubit:
// Add fields that let the UI show loading.
class ItemsState {
final List<Item> items;
final bool isRefreshing;
const ItemsState({
required this.items,
this.isRefreshing = false,
});
ItemsState copyWith({
List<Item>? items,
bool? isRefreshing,
}) {
return ItemsState(
items: items ?? this.items,
isRefreshing: isRefreshing ?? this.isRefreshing,
);
}
}
// Put the refresh logic in the Cubit and return a Future
class ItemsCubit extends Cubit<ItemsState> {
final ItemsRepository repo;
ItemsCubit(this.repo) : super(const ItemsState(items: []));
Future<void> refresh() async {
emit(state.copyWith(isRefreshing: true));
final items = await repo.fetchItems();
emit(state.copyWith(items: items, isRefreshing: false));
}
}
// Use it from RefreshIndicator
return RefreshIndicator(
onRefresh: () => context.read<ItemsCubit>().refresh(),
child: ListView(...),
);
Side effects
Here is how you would change the text in a TextField after an action is dispatched in AsyncRedux:
// The state
class AppState {
final Evt<String> changeEvt;
AppState({Evt<String>? changeEvt}) : changeEvt = changeEvt ?? Evt<String>.spent();
AppState copy({Evt<String>? changeEvt}) => AppState(changeEvt: changeEvt ?? this.changeEvt);
}
// The action
class ChangeText extends AppAction {
Future<AppState> reduce() async {
String newText = await repo.fetchTextFromApi();
return state.copy(changeEvt: Evt<String>(newText));
}
}
// The widget
Widget build(BuildContext context) {
String? newText = context.event((st) => st.changeEvt);
if (newText != null) controller.text = newText;
return TextField(controller: controller);
}
And this is how you would do it in Bloc/Cubit.
There is no built-in equivalent to AsyncRedux Evt,
so you have to add extra state fields (a token counter)
and wire up a BlocListener to perform the one-time side effect.
// The state
class AppState {
final String changeText;
final int changeTextToken;
const AppState({this.changeText = '', this.changeTextToken = 0});
AppState copyWith({String? changeText, int? changeTextToken})
=> AppState(
changeText: changeText ?? this.changeText,
changeTextToken: changeTextToken ?? this.changeTextToken,
);
}
// The Cubit
class AppCubit extends Cubit<AppState> {
AppCubit() : super(const AppState());
Future<void> changeText() async {
final newText = await repo.fetchTextFromApi();
emit(state.copyWith(
changeText: newText,
changeTextToken: state.changeTextToken + 1,
));
}
}
// The widget
Widget build(BuildContext context) {
return BlocListener<AppCubit, AppState>(
listenWhen: (prev, next) => prev.changeTextToken != next.changeTextToken,
listener: (context, state) {
controller.text = state.changeText;
},
child: TextField(controller: controller),
);
}
Testing
Here we show how a complex business flow can be tested with AsyncRedux, without involving any UI code.
Let's create some state that contains a Product with a name and price.
We have two actions: Search, which searches for a product by name;
and SavePrice, which saves a new price for the product.
// Domain model
class Product {
final String name;
final double price;
const Product({required this.name, required this.price});
Product copyWith({String? name, double? price}) => Product(
name: name ?? this.name,
price: price ?? this.price,
);
}
// App state
class AppState {
final Product? product;
const AppState({this.product});
AppState copy({Product? product}) => AppState(product: product ?? this.product);
}
// Action
class Search extends ReduxAction<AppState> {
final ProductRepo repo;
final String name;
Search({required this.repo, required this.name});
Future<AppState?> reduce() async {
final product = await repo.searchByName(name);
return state.copy(product: product);
}
}
// Action
class SavePrice extends ReduxAction<AppState> {
final String name;
final double price;
SavePrice({required this.name, required this.price});
Future<AppState?> reduce() async {
if (price < 0) throw UserException('Price cannot be negative');
await repo.savePrice(name: name, price: price);
return state.copy(product: Product(name: name, price: price));
}
}
Now, here is the test we are going to create:
The test simulates a user searching for a product by name, waiting for the asynchronous search to finish, and verifying that the product is loaded into the state. It then simulates editing the product price and saving it, again waiting for the save action to complete successfully and checking that the state reflects the new price. Then, the test performs another search to confirm that the updated price was persisted and is returned by the repository. Finally, we test that trying to save a negative invalid price results in an error that can be shown in the UI, and that the price does not change in the state.
void main() {
test('Search loads product, save updates price, reload confirms persistence, '
'and saving an invalid price fails without changing state.', () async {
final store = Store<AppState>(initialState: AppState());
final repo = SimulatedProductRepo({'Coffee': const Product(name: 'Coffee', price: 10.0)});
// 1. User taps Search
await store.dispatchAndWait(Search(name: 'Coffee'));
expect(store.state.product, Product(name: 'Coffee', price: 10.0));
// 2. User edits price in the dialog and taps Save
await store.dispatchAndWait(SavePrice(name: 'Coffee', price: 12.5));
expect(store.state.product, Product(name: 'Coffee', price: 12.5));
// 3) User searches again to confirm persisted value
final reloadStatus = await store.dispatchAndWait(Search(name: 'Coffee'));
expect(store.state.product, Product(name: 'Coffee', price: 12.5));
// 6. When user tries to save an invalid negative price, state is not changed
final status = await store.dispatchAndWait(SavePrice(name: 'Coffee', price: -5.0));
expect(status.isCompletedFailed, isTrue);
expect(store.state.product, Product(name: 'Coffee', price: 12.5));
});
}
Ok, now let's see how to do the same test with Bloc/Cubit.
In Bloc/Cubit, loading flags and errors are usually part of the state, so they are also something you test.
Below is a minimal Cubit version that matches the same flow and the same expectations as the AsyncRedux version.
// App state
class AppState {
final Product? product;
final bool isSearching;
final bool isSaving;
final String? error;
const AppState({
this.product,
this.isSearching = false,
this.isSaving = false,
this.error,
});
AppState copyWith({
Product? product,
bool? isSearching,
bool? isSaving,
String? error,
bool clearError = false,
}) {
return AppState(
product: product ?? this.product,
isSearching: isSearching ?? this.isSearching,
isSaving: isSaving ?? this.isSaving,
error: clearError ? null : (error ?? this.error),
);
}
}
// Cubit
class AppCubit extends Cubit<AppState> {
final ProductRepo repo;
AppCubit(this.repo) : super(const AppState());
Future<void> search(String name) async {
emit(state.copyWith(isSearching: true, clearError: true));
try {
final product = await repo.searchByName(name);
emit(state.copyWith(product: product, isSearching: false));
} catch (e) {
emit(state.copyWith(isSearching: false, error: e.toString()));
}
}
Future<void> savePrice({required String name, required double price}) async {
if (price < 0) {
emit(state.copyWith(error: 'Price cannot be negative'));
return;
}
emit(state.copyWith(isSaving: true, clearError: true));
try {
await repo.savePrice(name: name, price: price);
emit(state.copyWith(
product: Product(name: name, price: price),
isSaving: false,
));
} catch (e) {
emit(state.copyWith(isSaving: false, error: e.toString()));
}
}
}
And here is the equivalent test in Bloc/Cubit:
void main() {
test(
'Search loads product, save updates price, reload confirms persistence, '
'and saving an invalid price fails without changing state.',
() async {
final cubit = AppCubit(repo);
addTearDown(cubit.close);
final repo = SimulatedProductRepo({'Coffee': const Product(name: 'Coffee', price: 10.0)});
// 1. User taps Search. Spinner shows while searching.
final searchFuture = cubit.search('Coffee');
expect(cubit.state.isSearching, isTrue); // spinner on
await searchFuture;
// 2. Verify product loaded and spinner off
expect(cubit.state.isSearching, isFalse);
expect(cubit.state.error, isNull);
expect(cubit.state.product?.name, 'Coffee');
expect(cubit.state.product?.price, 10.0);
// 3. User edits price in the dialog and taps Save. Spinner shows while saving.
final saveFuture = cubit.savePrice(name: 'Coffee', price: 12.5);
expect(cubit.state.isSaving, isTrue); // spinner on
await saveFuture;
// 4. Verify price updated and spinner off
expect(cubit.state.isSaving, isFalse); // spinner off
expect(cubit.state.error, isNull);
expect(cubit.state.product?.price, 12.5);
// 5. User searches again to confirm persisted value
await cubit.search('Coffee');
expect(cubit.state.error, isNull);
expect(cubit.state.product?.price, 12.5);
// 6. When user tries to save an invalid negative price, state is not changed
await cubit.savePrice(name: 'Coffee', price: -5.0);
expect(cubit.state.product?.price, 12.5);
// 7. Verify error is set
expect(cubit.state.error, 'Price cannot be negative');
},
);
}
And here is the same test using the recommended bloc_test package:
void main() {
blocTest<AppCubit, AppState>(
'Search loads product, save updates price, reload confirms persistence, '
'and saving an invalid price fails without changing state.',
build: () {
final repo = SimulatedProductRepo({'Coffee': const Product(name: 'Coffee', price: 10.0)});
return AppCubit(repo);
},
act: (cubit) async {
await cubit.search('Coffee');
await cubit.savePrice(name: 'Coffee', price: 12.5);
await cubit.search('Coffee');
await cubit.savePrice(name: 'Coffee', price: -5.0);
},
expect: () => [
// 1) Search started
const AppState(isSearching: true),
// 2) Search finished
const AppState(product: Product(name: 'Coffee', price: 10.0)),
// 3) Save started
const AppState(
product: Product(name: 'Coffee', price: 10.0),
isSaving: true,
),
// 4) Save finished
const AppState(product: Product(name: 'Coffee', price: 12.5)),
// 5) Search again started
const AppState(
product: Product(name: 'Coffee', price: 12.5),
isSearching: true,
),
// 6) Search again finished
const AppState(product: Product(name: 'Coffee', price: 12.5)),
// 7) Invalid save sets error, keeps price
const AppState(
product: Product(name: 'Coffee', price: 12.5),
error: 'Price cannot be negative',
),
],
);
}
Similarities
For completeness, here are some features that can be implemented with a similar amount of code in both AsyncRedux and Bloc/Cubit.
Optimistic Updates
Here's how to implement saving a value with optimistic updates in AsyncRedux,
by using with OptimisticCommand:
class SaveTodo extends AppAction with OptimisticCommand {
final Todo newTodo;
SaveTodo(this.newTodo);
Object? optimisticValue() => state.todoList.add(newTodo);
Object? getValueFromState(AppState state) => state.todoList;
AppState applyValueToState(AppState state, Object? value) => state.copy(todoList: value);
Future<Object?> sendCommandToServer(Object? value) => saveTodo(newTodo);
Future<Object?> reloadFromServer() => loadTodoList();
}
In Bloc/Cubit, you can do something similar. First, define the following mixin:
mixin OptimisticCommand<State> on Cubit<State> {
Future<void> OptimisticCommand<T>({
required T Function() optimisticValue,
required T Function(State state) getValueFromState,
required State Function(State state, T value) applyState,
required Future<void> Function(T value) sendCommandToServer,
Future<T> Function()? reloadFromServer,
}) async {
final initialValue = getValueFromState(state);
final _optimisticValue = optimisticValue();
// 1. Optimistic update
emit(applyState(state, _optimisticValue));
try {
// 2. Save to server
await sendCommandToServer(_optimisticValue);
} catch (e) {
// 3. Rollback only if state still has our optimistic value
if (getValueFromState(state) == _optimisticValue) {
emit(applyState(state, initialValue));
}
rethrow;
} finally {
// 4. Reload from server
if (reloadFromServer != null) {
try {
final reloadedValue = await reloadFromServer();
emit(applyState(state, reloadedValue));
} catch (_) {}
}
}
}
}
Then, use it like this:
class TodoCubit extends Cubit<AppState> with OptimisticCommand<AppState> {
final TodoRepository repository;
TodoCubit(this.repository) : super(AppState.initial());
Future<void> saveTodo(Todo newTodo) async {
await OptimisticCommand<List<Todo>>(
optimisticValue: () => state.todoList.add(newTodo),
getValueFromState: (state) => state.todoList,
applyValueToState: (state, value) => state.copy(todoList: value),
sendCommandToServer: (_) => repository.saveTodo(newTodo),
reloadFromServer: () => repository.loadTodoList(),
);
}
}
Action status
Suppose you want to load some information and then pop the current screen only if the loading succeeded.
Here's how to do it with AsyncRedux, using the status.isCompletedOk property:
class SaveUserAction extends AppAction {
final String userName;
SaveUserAction(this.userName);
Future<AppState?> reduce() async {
final Id? userId = await repo.saveUser(userName);
if (userId == null) throw UserException('Save failed');
return state.copyWith(user: User(userId, name: userName));
}
}
// In the widget
onPressed: () async {
var status = await dispatchAndWait(SaveUserAction(userName));
if (status.isCompletedOk && context.mounted) Navigator.pop(context);
}
To do it with Bloc/Cubit, your Cubit method should return true in case of success:
class UserCubit extends Cubit<UserState> {
final UserRepository repo;
UserCubit(this.repo) : super(null);
Future<bool> saveUser(String userName) async {
final Id? userId = await repo.saveUser(userName);
final bool success = (userId != null);
if (success) emit(state.copyWith(user: User(userId!, name: userName)));
return success;
}
}
// In the widget
onPressed: () async {
var ok = await context.read<UserCubit>().saveUser(userName);
if (ok && context.mounted) Navigator.pop(context);
}