With package:bloc, why does an async Cubit method throw a StateError about emitting after close, and how do you prevent it?
answer
- the await outlives the screen
- close() shuts the state controller
- emit checks the closed controller
- guard with isClosed after await
- cancel own subscriptions in close()
basics
~20 sThe Cubit was closed, usually when its screen was disposed, while the method sat at an await; the next emit throws StateError('Cannot emit new states after calling close'). Check isClosed after each await and cancel owned subscriptions in close().
solid answer
~40 s`close()` closes the Cubit's internal state stream controller. `emit` checks that controller first and, if it is closed, reports `StateError('Cannot emit new states after calling close')` to `onError` and rethrows it. The usual path: `syncToday()` emits a loading state, awaits a repository call, the user leaves the screen, flutter_bloc's `BlocProvider` closes the Cubit, and the awaited call returns into an `emit`. Since bloc 8.0 that throws. Guard with `if (isClosed) return;` after every await that precedes an emit, and override `close()` to cancel any `StreamSubscription` or `Timer` the Cubit owns before returning `super.close()`, which is `@mustCallSuper`. Do not swallow the error globally; it points at work that outlives its screen.
code
dart · 37 linesimport 'dart:async';
import 'package:bloc/bloc.dart';
typedef WaterState = ({int glasses, int dailyLimit, bool syncing});
abstract interface class IntakeRepository {
Future<int> fetchTodayGlasses();
Stream<int> watchDailyLimit();
}
class WaterIntakeCubit extends Cubit<WaterState> {
WaterIntakeCubit(this._repository)
: super((glasses: 0, dailyLimit: 8, syncing: false)) {
_limitSubscription = _repository.watchDailyLimit().listen(
(limit) => emit(
(glasses: state.glasses, dailyLimit: limit, syncing: state.syncing),
),
);
}
final IntakeRepository _repository;
late final StreamSubscription<int> _limitSubscription;
Future<void> syncToday() async {
emit((glasses: state.glasses, dailyLimit: state.dailyLimit, syncing: true));
final glasses = await _repository.fetchTodayGlasses();
if (isClosed) return; // the screen may have closed the Cubit meanwhile
emit((glasses: glasses, dailyLimit: state.dailyLimit, syncing: false));
}
@override
Future<void> close() async {
await _limitSubscription.cancel();
return super.close();
}
}go deeper
Recall that a closed Cubit cannot emit, that isClosed tells you whether it is closed, and that a check after await prevents the error.
Explain the timeline: emit, await, the provider closes the Cubit, the future resumes, emit throws; and why closing cannot cancel the pending future.
Show production hygiene: guards after every await, close() overrides that cancel owned subscriptions and timers, and knowing which provider owns each Cubit.
Set conventions so long-running work never outlives its owner: lint rules, review checklists and a clear ownership model for Cubits created outside the widget tree.
## What close() does In the **bloc** package (bloc 9.2), a Cubit owns one broadcast `StreamController` for its states. `BlocBase.close()` does two things: - notifies the global observer that the instance is closing (`onClose`); - closes the state controller, which sends the done event to listeners. From then on `isClosed` is `true`, and `emit` refuses to work. Its first step is to check the controller; if it is closed, it throws `StateError('Cannot emit new states after calling close')`. The throw happens inside a `try` that calls `onError` with the error and then **rethrows**, so both the Cubit's error hook and the caller see it. This became a hard error in bloc 8.0, which made `emit` and `Bloc.add` throw on a closed instance. ## How the error happens The classic timeline in a water-intake tracker: 1. The screen's `BlocProvider(create: ...)` builds a `WaterIntakeCubit`. 2. The user pulls to refresh; `syncToday()` emits a syncing state and awaits the repository. 3. The user navigates back. The provider leaves the tree and closes the Cubit. 4. The network call completes. `syncToday()` resumes after its `await` and calls `emit`. 5. `emit` finds the controller closed and throws; unless some caller still awaits the method and handles it, the error surfaces as an uncaught async error. Closing does **not** cancel the pending `Future`: Dart has no mechanism for that, so the method always resumes. ## Guarding async methods Put an `isClosed` check between every `await` and the `emit` that follows it: ```dart Future<void> syncToday() async { emit((glasses: state.glasses, dailyLimit: state.dailyLimit, syncing: true)); final glasses = await _repository.fetchTodayGlasses(); if (isClosed) return; emit((glasses: glasses, dailyLimit: state.dailyLimit, syncing: false)); } ``` The check is cheap and states intent: the result no longer has an audience, so the method stops. ## Owning subscriptions and timers A Cubit that listens to a repository stream or runs a `Timer` must stop them itself; `BlocBase.close()` only closes its own state controller. Override `close()`, cancel what you own, then return `super.close()` — it is annotated `@mustCallSuper`, and skipping it leaves the state stream open and the observer uninformed. ## Approaches compared | Approach | Effect | Verdict | |---|---|---| | `if (isClosed) return;` after awaits | method stops cleanly | use it | | cancel subscriptions in `close()` | no listener callback emits later | use it | | catch `StateError` around `emit` | hides the symptom; `onError` still fires | avoid | | never close the Cubit | leaks the controller and subscriptions | avoid | ## Who closes the Cubit - flutter_bloc's `BlocProvider(create: ...)` closes the Cubit it created when the provider is removed. - `BlocProvider.value` does **not** close it, because it did not create it; the owner must. - A Cubit constructed in a service, a test or `main` needs an explicit `await cubit.close()`. Knowing the owner tells you when step 3 of the timeline can happen, and therefore which methods need the guard.
- Why not simply wrap emit in a try/catch for StateError?It hides the symptom rather than the cause. The work still runs to completion after the screen is gone, `onError` and any observer still receive the error because emit reports it before rethrowing, and a genuine misuse elsewhere gets masked too. An `isClosed` check stops the method cleanly at the point its result has no audience.
- Who is responsible for calling close() on a Cubit?Whoever created it. flutter_bloc's `BlocProvider(create: ...)` closes the Cubit it created when the provider leaves the tree; `BlocProvider.value` does not, because it only exposes an existing instance. A Cubit built in a service, a test or `main` needs an explicit `await cubit.close()`.
saying these in an interview costs you the question
- The error means the Cubit was never provided to the widget tree
- Catching StateError around emit is the proper fix
- close() cancels any await still pending inside Cubit methods
- A Cubit closes itself when its last stream listener cancels
- Overriding close() without calling super.close() is fine once subscriptions are cancelled