When I first started using flutter_bloc, I thought I had a good grasp of cubits and blocs. However, I soon realized my understanding was biased by my previous experience with the MVVM pattern. Initially, I assumed that each screen could be managed by a single cubit handling both logic and state. This approach seemed intuitive at first.
However, as screen complexity grew, managing all state changes from a single cubit became increasingly challenging. To address this, I began using separate cubits for individual features, with each "feature" representing a distinct functional unit (like creating or deleting an element).
This new approach served me well for a while, seemingly solving my immediate problems. But as with many solutions in software development, new challenges eventually emerged.
To illustrate these challenges, let's consider a common scenario: user authentication. Imagine an application where, upon login, you need to direct the user to either a welcome screen or an email verification screen, depending on whether their account is verified.
My initial solution was to emit a state indicating the user had logged in, then use the user's emailVerifiedAt value to determine which screen to navigate to. While this worked, it revealed some limitations in my state management approach that I'll explore further.
Look at this code:
// State class
@freezed
class LoginState with _$LoginState {
const factory LoginState.initial() = _Initial;
const factory LoginState.loading() = _Loading;
const factory LoginState.success({required User user}) = _Success;
const factory LoginState.failure({required String error}) = _Failure;
}
// Cubit class
class LoginCubit extends Cubit<LoginState> {
final AuthRepository authRepository;
LoginCubit({required this.authRepository}) : super(const LoginState.initial());
Future<void> login(String email, String password) async {
emit(const LoginState.loading());
try {
// Simulate API call
final user = authRepository.login(email: email, password: password);
emit(LoginState.success(user: user));
} catch (e) {
emit(LoginState.failure(error: e.toString()));
}
}
}
The problem with this approach is that it leaves business logic in the view. The view must take care of navigation, of course, but it doesn't have to decide on its own where to go. It should simply react to a state.
What would it look like (code wise) :
// State class
@freezed
class LoginState with _$LoginState {
const factory LoginState.initial() = _Initial;
const factory LoginState.loading() = _Loading;
const factory LoginState.loggedIn({required User user}) = _Success;
const factory LoginState.accountVerificationNeeded({required User user}) = _Success;
const factory LoginState.error({required String error}) = _Failure;
}
// Cubit class
class LoginCubit extends Cubit<LoginState> {
final AuthRepository authRepository;
LoginCubit({required this.authRepository}) : super(const LoginState.initial());
Future<void> login(String email, String password) async {
emit(const LoginState.loading());
try {
// Simulate API call
final user = authRepository.login(email: email, password: password);
if (user.emailVerifiedAt == null) {
emit(LoginState.accountVerificationNeeded(user: user));
} else {
emit(LoginState.loggedIn(user: user));
}
} catch (e) {
emit(LoginState.failure(error: e.toString()));
}
}
}
What's happening here:
The cubit checks whether the user has verified their account or not and emits a state accordingly
The UI will not have to decide where it should go by checking if the user has verified their account or not. It will simply react to either the loggedIn state and navigate to the HomeRoute or to VerifyEmailRoute if the state is accountVerificationNeeded
So in the view, instead of this:
BlocListener<LoginCubit, LoginState>(
listener: (context, state) {
state.whenOrNull(
success: (user) {
if (user.emailVerifiedAt != null) {
context.router.push(HomeRoute());
} else {
context.router.push(VerifyEmailRoute());
}
},
failure: (error) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error)));
},
);
},
)
You'll have this:
BlocListener<LoginCubit, LoginState>(
listener: (context, state) {
state.whenOrNull(
loggedIn: (user) => context.router.push(HomeRoute()),
accountVerificationNeeded: (user) => context.router.push(VerificationRoute()),
failure: (error) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error)));
},
);
},
)
Why is this approach better?
Readability: While code readability is subjective, in my opinion, the second version (v2) is much more readable and easier to understand at first glance. It presents a clearer separation of concerns and a more straightforward flow of logic.
Single Responsibility Principle (SRP): This approach better adheres to the Single Responsibility Principle. The separation of responsibilities is clearer, with all logic delegated to the cubit side. The view simply responds to what the cubit tells it to do via state changes.
To illustrate the benefits, let's consider a more complex scenario: initiating a credit card payment that requires NFC (Near Field Communication) to be enabled.
In my previous approach, I might have passed an NfcService to the view, which would then check if NFC was enabled directly from the UI. However, with the new approach:
The Cubit would be responsible for checking NFC status and initiating payment.
If NFC is disabled, the Cubit would emit a state indicating "NFC not available", prompting the view to display a dialog box.
If NFC is enabled, the Cubit would emit a state like "initiating payment," and the view would respond by displaying a loader or guiding the user through the process.
This new method keeps the view simple and focused on presentation, while the Cubit manages the business logic and state transitions. It results in a more modular, testable, and maintainable codebase.
Throughout this article, we've seen how our understanding and implementation of Cubits and Blocs can mature over time, leading to more efficient and maintainable code.
Key takeaways:
Separation of Concerns:
The UI doesn't "decide"; the Cubit/Bloc does. This principle keeps our UI layers simple and focused on presentation, while business logic remains in the Cubit/Bloc.
This separation enhances readability, maintainability, and testability of our code.
Single Responsibility:
- Using multiple Cubits/Blocs for different features within a screen can lead to cleaner, more manageable code, especially as application complexity grows.
State-Driven Development:
- By emitting states from the Cubit/Bloc and having the UI respond to these states, we create a more predictable and easier-to-debug application flow.
Expanded Role of Repositories:
Repositories aren't limited to data fetching. They can also be used to share data across Cubits/Blocs, as highlighted in the BLoC library's architecture guidelines (https://bloclibrary.dev/architecture/#connecting-blocs-through-domain).
This approach can help in managing complex state interactions between different parts of your application.
Evolving Patterns:
- As we've seen with the login example, our approach to state management can and should evolve as we encounter new challenges and gain more experience.
By adhering to these principles, we can create Flutter applications that are not only functional but also scalable and maintainable. Remember, the goal is to keep your UI reactive and your business logic contained and testable. As you continue to work with flutter_bloc, you'll likely discover even more ways to refine your state management approach.
Read also: https://stevenosse.com/data-flow-and-readability-within-a-widget-tree