I'm a big fan of the Bloc library for Flutter apps, and when coupled with the freezed package for creating immutable data classes and sealed unions, I think this pattern really shines.
Sealed whats?
Let's start with some definitions. In other languages (like Kotlin), there are patterns available such as union types, sealed classes, and pattern-matching.
These are all slightly different expressions of the same idea: that you can create a class with a fixed set of subclasses, linking together multiple otherwise separate types under one โumbrellaโ class.
Shapes are a good example of how this could be used. Here's an example in Kotlin:
sealed class Shape
data class Square(val sideLength: Int): Shape()
data class Rectangle(val length: Int, val width: Int): Shape()
data class Circle(radius: Int): Shape()
Here we are defining 3 classes โ Square
, Rectangle
, and Circle
. Each has its distinct properties, but we're able to have them all extend the Shape
superclass.
More importantly, because Shape
is a sealed class
, no other subtypes can be defined outside this file; in other words, we can restrict the subtypes of Shape
to the ones we define.
So why would this be helpful when using the Bloc pattern?
Let's consider a simple Counter bloc. It would have two Events: CounterIncremented
and CounterDecremented
. In pure Dart, we would have to do something like this:
abstract class CounterEvent {}
class CounterIncremented extends CounterEvent {
CounterIncremented(this.incrementBy);
final int incrementBy;
}
class CounterDecremented extends CounterEvent {
CounterDecremented(this.decrementBy);
final int decrementBy;
}
This is fine, but there are a couple of noteworthy issues:
- For Blocs with lots of events, you can see how this approach would become very verbose and cumbersome.
- The above snippet doesn't take equality operators into account; that's more boilerplate we'd need to add.
- When we eventually implement this Bloc, our code will be full of type-casting and type-checking, which is easy to make a mess with.
If we were writing Kotlin, we could solve these problems by implementing CounterEvent
as a sealed class
with each event type as a data class extending the base class.
sealed class CounterEvent
data class CounterIncremented(val incrementBy: Int) : CounterEvent()
data class CounterDecremented(val decrementBy: Int) : CounterEvent()
Kotlin's data class
construct gives us immutability and equality without boilerplate.
But we're not writing Kotlin, and Dart doesn't have support for sealed classes out-of-the-box. We need to find an alternative!
Freezed Events
The freezed package gives us a couple of important abilities: we can create immutable data classes, and we can create sealed unions. Perfect!
Here's what our Counter events would look like using a freezed union:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'counter_event.freezed.dart';
@freezed
class CounterEvent with _$CounterEvent {
const factory CounterEvent.incremented(int incrementBy) = CounterIncremented;
const factory CounterEvent.decremented(int decrementBy) = CounterDecremented;
}
With this implementation, freezed has given us two immutable event types with equality operators. It even generates bonus stuff like copyWith
and toString
methods. This is a totally battle-ready implementation of our CounterEvent
type.
When it comes to actually using instances of CounterEvent
, freezed also gives us access to some pattern-matching syntax. Here's how we could implement our event handler:
event.when(
incremented: (incrementBy) => emit(state + incrementBy),
decremented: (decrementBy) => emit(state - decrementBy),
);
This syntax is helpful for a couple of reasons. First, forces us to account for every possibility; in pure Dart with type-casting, we could easily forget about or ignore certain event types. Second, it's super easy to read and understand!
Freezed States
Let's look at something more complex: writing a state type using freezed. Let's say we have some data to load: we'll probably want an initial state, an error state, and a success state. We also need a loading indicator to show the user when the data is being refreshed.
Using freezed, we can implement a state class like this:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'data_state.freezed.dart';
@freezed
class DataState with _$DataState {
const factory DataState.initial(bool isLoading) = DataInitial;
const factory DataState.error(String message, bool isLoading) = DataError;
const factory DataState.success(Data data, bool isLoading) = DataSuccess;
}
Then, we can implement a BlocBuilder
like this:
BlocBuilder<DataBloc, DataState>(
builder: (context, state) {
return Column(
children: [
if (state.isLoading) _buildProgressIndicator(),
state.when(
initial: (_) => _buildInitial(),
error: (message, _) => _buildError(message),
success: (data, _) => _buildSuccess(data),
),
],
);
},
);
There are a couple of things to notice here:
- Because every state type has an
isLoading
property, we can access it without knowing what subtype of state we have. Thanks, freezed! - Again, the pattern-matching syntax provided by freezed is extremely useful here in giving us some super-readable code which forces us to handle all possible states.
Is this the perfect solution?
No.
There is no perfect solution! It could very easily be argued that freezed classes are still pretty verbose, and some developers prefer to not rely on code generation in their apps. And some folks just don't like Bloc at all for some reason. That's all fine โ do what works for you!
But, having worked on more than 10 new Flutter projects in the past year or so, I've found that this pattern allows us to build and iterate quickly on our code, and improves my team's ability to understand one another's work easily.
As with lots of software engineering patterns and practices, there's no single right way to do things; the most important thing you can do is to pick one pattern and stick to it consistently.
Liked this? Buy me a coffee.
Find me elsewhere online here.
Top comments (1)
I agree freezed and Bloc is a nice combo. However, I've yet to imagine a scenario where I need equality for Bloc
events
. Obviously its very useful for Blocstate
, for unit tests etc.. and its nice thatstate.when(...)
forces you to handle all states.Can you provide an example where you're directly comparing two different events and the equality override becomes useful?