Generic States with Bloc
flutterdartblocstatetutorialsstate managementasync operationscode readabilitymaintainability
In my experience working with Flutter and bloc for the last few years, I’ve found that the one thing I’ve written one too many times is the basic state class like WeatherLoading
, WeatherSuccess
, and WeatherFailure
.
This is a very common pattern when working with bloc, but it can get very repetitive and boring to write the same boilerplate over and over again.
sealed class BaseState<T> {
const BaseState();
}
class Loading<T> extends BaseState<T> {
const Loading();
}
class Success<T> extends BaseState<T> {
final T value;
const Success(this.value);
}
class Error<T> extends BaseState<T> {
final String message;
const Error(this.message);
}
This is a very simple approach to a generic state class, but it’s already a big improvement over writing the same boilerplate over and over again. You can now use this class across your app mostly for simple async operations. You can also add more states like Empty
or Initial
if you need them.
Note that we are using a sealed class. This is very helpful because the compiler will warn us if we forget to handle a state in a switch statement using the exhaustiveness checking feature.
class WeatherCubit extends Cubit<BaseState<Weather>> {
WeatherCubit(this.api) : super(const Loading());
final WeatherAPI api;
Future<void> getWeather(String city) async {
emit(const Loading());
try {
final weather = await api.getWeather(city);
emit(Success(weather));
} catch (e) {
emit(Error(e.toString()));
}
}
}
This is very simple example, but you can see how much cleaner and readable the code is.
With this approach, I like to add a few methods to make it even easier to work with.
sealed class BaseState<T> {
const BaseState();
// Getting the error message only if the state is Error...
String? get errorMessage => switch (this) {
Error<T>(:final message) => message,
Loading<T>() => throw UnimplementedError(),
Success<T>() => throw UnimplementedError(),
};
// Getting the value only if the state is Success...
T? get valueOrNull => switch (this) {
Error<T>() => throw UnimplementedError(),
Loading<T>() => throw UnimplementedError(),
Success<T>(:final value) => value,
};
}
We can expand this idea even more and create a reusable widget that manages all the generic states for us:
class BaseStateBuilder<T> extends StatelessWidget {
final BaseState<T> state;
final Widget Function(T value) onSuccess;
final Widget Function(String message)? onError;
final Widget Function()? onLoading;
const BaseStateBuilder({
required this.state,
required this.onSuccess,
this.onError,
this.onLoading,
super.key,
});
@override
Widget build(BuildContext context) {
return switch (state) {
Loading<T>() => onLoading?.call() ?? const CircularProgressIndicator(),
Error<T>(:final message) => onError?.call(message) ?? Text(message),
Success<T>(:final value) => onSuccess(value),
};
}
}
Now we can simply use this widget in our UI:
@override
Widget build(BuildContext context) {
return Center(
child: BlocBuilder<WeatherCubit, BaseState<Weather>>(
builder: (context, state) {
return BaseStateBuilder<Weather>(
state: state,
onLoading: () => const CircularProgressIndicator(),
onError: (message) => Text(message),
onSuccess: (weather) => Text(weather.city),
);
},
),
);
}
While this approach may not be the best for every case, it’s a good starting point for most cases and you can always expand it to fit your needs.
I really think whether you are using bloc, Value Notifier, or any other state management solution, using generic states can make your code more readable and maintainable on the long run.
If you want more information about this topic, I recommend checking out AsyncValue from Riverpod.
I hope you found this article helpful. If you have any questions or feedback, feel free to contact me on Twitter or email me at mail@lorensala.com
Happy coding! 🚀