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
requiredor have@Default()— no nullable fields in states. Use sensible defaults (empty lists,false,Model.empty()). effectsalways defaults to<AppEffect>[]— this is the mechanism for side effects. Without it, the UI cannot show snackbars, navigate, or open dialogs.implements HasEffects— required forEffectMixinto 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()andlogger— required byEffectMixin. ThecopyStateWithEffectsimplementation always usesstate.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 viaemit(copyStateWithEffects(state, [...currentEffects, effect])) - BlocListener in the UI detects
state.effects.isNotEmptyand callssl<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 inclose()
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
- Freezed class implementing
Create the cubit file at
lib/application/cubits/<feature>_cubit.dart:- Extends
Cubit<State>withEffectMixin<State> - Implements
copyStateWithEffectsandlogger - Constructor injection of all dependencies
_init()for stream subscriptionsclose()for cleanup
- Extends
Register in DI at
lib/core/di/locator.dart:sl.registerFactory<FeatureCubit>(() => FeatureCubit( sl<FeatureNotifier>(), sl<FeatureRepositoryAnilist>(), ));Wire to screen — see
unyo-ui-presentationskill for BlocProvider/BlocListener/BlocBuilder pattern.Run code generation:
flutter pub run build_runner build --delete-conflicting-outputsRun analysis:
flutter analyze
Common Pitfalls
- Forgetting
implements HasEffects: The state must implement this interface forEffectMixinto work. Without it,_currentEffectsthrows aStateError. - Not calling
clearEffects()from the UI:BlocListenermust callcubit.clearEffects()after processing effects, otherwise effects will keep firing on every rebuild. - Storing
BuildContextin a cubit: Never do this. Cubits outlive widget trees. Use effects instead. - Mutating state directly: Freezed states are immutable. Always use
state.copyWith(...). - Missing
copyStateWithEffectsimplementation: Every cubit that mixes inEffectMixinmust implement this. It always looks likestate.copyWith(effects: effects). - Not canceling subscriptions: Will cause memory leaks and phantom updates on disposed cubits.
- Registering cubits as singletons: Cubits must be
registerFactory, notregisterSingletonorregisterLazySingleton. Each screen needs its own instance.
Cross-references
- Effect types and handler: Examined in
unyo-ui-presentationskill - Notifier subscription pattern: See
unyo-reactive-notifiersskill - DI registration: See
unyo-dependency-injectionskill - State entities used in states: See
unyo-domain-data-layerskill