| name | unyo-bloc-state-management |
| description | How to create cubits, define states, use the EffectMixin pattern, and handle side effects in the Unyo Flutter app. Use this skill whenever creating a new cubit, defining a new state class, adding navigation/dialog/snackbar effects, handling errors in cubits, or wiring cubits to screens with BlocProvider and BlocListener. Also use when modifying existing cubit behavior or debugging state-related issues. |
Unyo BLoC State Management (Cubit + Effect Pattern)
Unyo uses Cubit (from flutter_bloc) for state management, enhanced with a custom EffectMixin that handles side effects (navigation, dialogs, snackbars) through the state itself. This is the core pattern for all business logic in the app.
Architecture Overview
lib/application/
āāā cubits/
ā āāā home_cubit.dart # Cubit classes
ā āāā anime_cubit.dart
ā āāā anime_details_cubit.dart
ā āāā video_cubit.dart
ā āāā effect_mixin.dart # Mixin for side effects
ā āāā ...
āāā states/
ā āāā home_state.dart # Freezed state classes
ā āāā anime_state.dart
ā āāā anime_details_state.dart
ā āāā ...
āāā effects/
āāā app_effects.dart # Effect type definitions
The Three-Part Pattern
Every feature's state management consists of:
- State ā Immutable Freezed class carrying all data + a list of effects
- Cubit ā Business logic that emits new states, mixes in
EffectMixin
- Effect Handler ā UI layer that reads effects from state and executes them
Pattern: Defining a State
States are Freezed classes that implement HasEffects. Every state must include a List<AppEffect> effects field.
// lib/application/states/home_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:unyo/application/cubits/effect_mixin.dart';
import 'package:unyo/application/effects/app_effects.dart';
import 'package:unyo/core/enums/selected_menu_option.dart';
import 'package:unyo/domain/entities/anime.dart';
import 'package:unyo/domain/entities/manga.dart';
import 'package:unyo/domain/entities/user.dart';
part 'home_state.freezed.dart';
@freezed
abstract class HomeState with _$HomeState implements HasEffects {
const factory HomeState({
required User loggedUser,
required SelectedMenuOption selectedMenuOption,
required List<Anime> continueWatching,
required List<Manga> continueReading,
required List<String> mediaCoverImages,
required bool isLoading,
required bool userLoaded,
@Default(<AppEffect>[]) List<AppEffect> effects, // ALWAYS include this
}) = _HomeState;
const HomeState._();
@override
List<AppEffect> get stateEffects => effects; // ALWAYS implement this
}
State conventions
- All fields
required or have @Default() ā no nullable fields in states. Use sensible defaults (empty lists, false, Model.empty()).
effects always defaults to <AppEffect>[] ā this is the mechanism for side effects. Without it, the UI cannot show snackbars, navigate, or open dialogs.
implements HasEffects ā required for EffectMixin to work.
const HomeState._() ā private constructor needed when you have method overrides alongside Freezed.
- Never have computed getters in the state ā states are pure data. Put computed logic in the cubit or utility methods.
Initial state pattern
States are created with all required fields in the cubit's super() call:
: super(HomeState(
loggedUser: UserModel.empty(),
selectedMenuOption: SelectedMenuOption.home,
continueWatching: [],
continueReading: [],
mediaCoverImages: [],
isLoading: true,
userLoaded: false,
));
Use Model.empty() factory constructors for initial values, not null.
Pattern: Creating a Cubit
Cubits extend Cubit<State> and mix in EffectMixin<State>:
// lib/application/cubits/home_cubit.dart
class HomeCubit extends Cubit<HomeState> with EffectMixin<HomeState> {
// Repositories
final UserRepositoryAnilist _userRepositoryAnilist;
final AnimeRepositoryAnilist _animeRepositoryAnilist;
// Notifiers
final UserNotifier _loggedUserNotifier;
final AnimeNotifier _selectedAnimeNotifier;
final MangaNotifier _selectedMangaNotifier;
final MediaListNotifier _selectedMediaListNotifier;
final ReloadNotifier _reloadNotifier;
// Subscriptions (for notifiers)
late StreamSubscription<User> _newLoggedUserSubscription;
late StreamSubscription<ReloadType> _reloadSubscription;
// Logger
final Logger _logger = sl<Logger>();
HomeCubit(
this._loggedUserNotifier,
this._selectedAnimeNotifier,
this._selectedMangaNotifier,
this._selectedMediaListNotifier,
this._userRepositoryAnilist,
this._animeRepositoryAnilist,
this._menuBarNotifier,
this._reloadNotifier,
) : super(HomeState(
loggedUser: UserModel.empty(),
selectedMenuOption: SelectedMenuOption.home,
continueWatching: [],
continueReading: [],
mediaCoverImages: [],
isLoading: true,
userLoaded: false,
)) {
_init();
}
@override
State copyStateWithEffects(State state, List<AppEffect> effects) {
return state.copyWith(effects: effects);
}
@override
Logger get logger => _logger;
void _init() {
_newLoggedUserSubscription = _loggedUserNotifier.userStream.listen((user) {
emit(state.copyWith(loggedUser: user));
if (!state.userLoaded) {
_getUserInfo(user);
emit(state.copyWith(userLoaded: true, isLoading: false));
}
});
_reloadSubscription = _reloadNotifier.reloadStream.listen((reloadType) async {
if (reloadType == ReloadType.homeMediaListEntryUpdated) {
await _getUserInfo(state.loggedUser, ignoreCacheAnime: true);
}
});
}
@override
Future<void> close() {
_newLoggedUserSubscription.cancel();
_reloadSubscription.cancel();
return super.close();
}
}
Cubit conventions
- Mixin
EffectMixin<State> ā this is non-optional. Every cubit needs it for navigation and user feedback.
- Implement
copyStateWithEffects() and logger ā required by EffectMixin. The copyStateWithEffects implementation always uses state.copyWith(effects: effects).
- Constructor injection ā all dependencies (repositories, notifiers) come through the constructor. The DI container provides them when creating the cubit.
- Private
_init() method ā called from constructor to set up stream subscriptions after fields are initialized.
- Always cancel subscriptions in
close() ā prevents memory leaks.
Pattern: EffectMixin ā Side Effects in Cubits
The EffectMixin provides methods for navigation, dialogs, and feedback without directly accessing BuildContext (which cubits should never hold):
Available effect methods
// Navigation effects
pushRouteEffect(path: "/animedetails"); // Push onto stack
replaceRouteEffect(path: "/login"); // Replace current route
navigateRouteEffect(path: "/tabs"); // Navigate within tabs
changeRouteTabEffect(context, path: "/anime"); // Switch tab in AutoTabsRouter
popRouteEffect(context); // Pop current route
// Dialog effects
showWidgetDialogEffect(dialog: MyDialog()); // Show arbitrary widget dialog
showDrawerDialogEffect(
drawerDialog: MyDrawer(),
backgroundColor: Colors.black54,
startPosition: AxisDirection.right,
); // Show slide-in drawer dialog
closeDialogEffect(context); // Close current dialog
// Feedback effects
showSnackBarEffect("Title", message: "Details", contentType: ContentType.failure);
showSnackBarEffect("Success!", message: "Saved", contentType: ContentType.success);
// Error handling (combines logging + snackbar)
handleError("Error fetching data: $e", stackTrace: stackTrace);
How effects flow
- Cubit calls
pushRouteEffect(path: "/animedetails")
- EffectMixin creates a
PushRouteEffect("/animedetails") and adds it to the state's effects list via emit(copyStateWithEffects(state, [...currentEffects, effect]))
- BlocListener in the UI detects
state.effects.isNotEmpty and calls sl<AppEffectHandler>().handleEffects(context, state.effects, cubit.clearEffects)
- AppEffectHandler pattern-matches on the effect type and calls
AutoRouter.of(context).pushPath(...)
- clearEffects() removes all effects from state after processing
This pattern keeps cubits free of BuildContext while still enabling navigation and UI feedback.
Why effects instead of direct navigation?
Cubits should not hold BuildContext references (they outlive the widget tree). Effects let cubits express intent ("navigate to anime details") without knowing how or when it happens. The UI layer handles the actual navigation.
Pattern: Emitting State Changes
// Simple property update
emit(state.copyWith(isLoading: true));
// Conditional logic
if (!state.userLoaded) {
await _getUserInfo(user);
emit(state.copyWith(userLoaded: true, isLoading: false));
}
// Error handling with effect
try {
final data = await _repository.getData();
emit(state.copyWith(data: data, isLoading: false));
} catch (e, stackTrace) {
handleError("Failed to load data: $e", stackTrace: stackTrace);
replaceRouteEffect(path: "/login");
}
Key emission rules
- Always use
state.copyWith() ā never mutate state directly. Freezed enforces this since all fields are immutable.
- Chain related emissions ā it's fine to emit multiple times in one method. Each emission triggers a rebuild in
BlocBuilder.
- Don't emit after async gaps without checking ā after an
await, the cubit might be closed. Wrap post-async logic in try/catch.
Pattern: Subscribing to Notifiers in Cubits
See the unyo-reactive-notifiers skill for the full pattern. The key steps:
- Declare
late StreamSubscription<T> fields
- Subscribe in
_init() called from constructor
- Call
.cancel() on all subscriptions in close()
late StreamSubscription<User> _userSub;
void _init() {
_userSub = _userNotifier.userStream.listen((user) {
emit(state.copyWith(loggedUser: user));
});
}
@override
Future<void> close() {
_userSub.cancel();
return super.close();
}
Creating a New Cubit+State: Complete Workflow
-
Create the state file at lib/application/states/<feature>_state.dart:
- Freezed class implementing
HasEffects
- Include
@Default(<AppEffect>[]) List<AppEffect> effects
- All required fields with sensible defaults
- Part directives for
*.freezed.dart
-
Create the cubit file at lib/application/cubits/<feature>_cubit.dart:
- Extends
Cubit<State> with EffectMixin<State>
- Implements
copyStateWithEffects and logger
- Constructor injection of all dependencies
_init() for stream subscriptions
close() for cleanup
-
Register in DI at lib/core/di/locator.dart:
sl.registerFactory<FeatureCubit>(() => FeatureCubit(
sl<FeatureNotifier>(),
sl<FeatureRepositoryAnilist>(),
));
-
Wire to screen ā see unyo-ui-presentation skill for BlocProvider/BlocListener/BlocBuilder pattern.
-
Run code generation:
flutter pub run build_runner build --delete-conflicting-outputs
-
Run analysis:
flutter analyze
Common Pitfalls
- Forgetting
implements HasEffects: The state must implement this interface for EffectMixin to work. Without it, _currentEffects throws a StateError.
- Not calling
clearEffects() from the UI: BlocListener must call cubit.clearEffects() after processing effects, otherwise effects will keep firing on every rebuild.
- Storing
BuildContext in a cubit: Never do this. Cubits outlive widget trees. Use effects instead.
- Mutating state directly: Freezed states are immutable. Always use
state.copyWith(...).
- Missing
copyStateWithEffects implementation: Every cubit that mixes in EffectMixin must implement this. It always looks like state.copyWith(effects: effects).
- Not canceling subscriptions: Will cause memory leaks and phantom updates on disposed cubits.
- Registering cubits as singletons: Cubits must be
registerFactory, not registerSingleton or registerLazySingleton. Each screen needs its own instance.
Cross-references
- Effect types and handler: Examined in
unyo-ui-presentation skill
- Notifier subscription pattern: See
unyo-reactive-notifiers skill
- DI registration: See
unyo-dependency-injection skill
- State entities used in states: See
unyo-domain-data-layer skill