skip to content

With package:bloc, why does an async Cubit method throw a StateError about emitting after close, and how do you prevent it?

level: seniorimportance: should knowfreq 38%

answer

  1. the await outlives the screen
  2. close() shuts the state controller
  3. emit checks the closed controller
  4. guard with isClosed after await
  5. cancel own subscriptions in close()

basics

~20 s

The 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 lines
dart
import '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

for a junior

Recall that a closed Cubit cannot emit, that isClosed tells you whether it is closed, and that a check after await prevents the error.

for a middle

Explain the timeline: emit, await, the provider closes the Cubit, the future resumes, emit throws; and why closing cannot cancel the pending future.

for a senior

Show production hygiene: guards after every await, close() overrides that cancel owned subscriptions and timers, and knowing which provider owns each Cubit.

for a principal

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