skip to content

In the bloc package, in what order do BlocObserver's onEvent, onTransition, onChange and onDone fire for one handled event, and what differs for a Cubit?

level: middleimportance: should knowfreq 35%

answer

  1. add fires first
  2. transition wraps the change
  3. both before state updates
  4. Cubit: onChange only
  5. equal state fires nothing

basics

~20 s

onEvent fires when add is called; each emit then fires onTransition followed by onChange, both before the state updates; onDone fires when the handler finishes. A Cubit has no events, so its emit fires onChange only.

solid answer

~40 s

In bloc 9, `add(event)` calls the bloc's `onEvent`, which forwards to `BlocObserver.onEvent`, before the event is queued. When the `on<E>` handler calls `emit`, the bloc first calls `onTransition` with the event, current state and next state, then `onChange` with just the two states; both run **before** `state` is updated, so reading `bloc.state` inside them still gives the old value. When the handler's future completes, `onDone` fires, carrying the error and stack trace if it threw. A `Cubit` has no events, so `emit` produces only `onChange`. An emit of a state equal to the current one fires nothing. Per-instance overrides of these hooks are `@mustCallSuper`: skipping `super` silences the global observer for that bloc.

go deeper

for a junior

Recall that onChange fires for every emit on both Bloc and Cubit, while onEvent and onTransition exist only for Bloc.

for a middle

Walk through the full order - onEvent, onTransition, onChange, state update, onDone - and explain why bloc.state inside onChange is still the old value.

for a senior

Diagnose missing logs or persistence caused by an override that skips super, and pick onChange over onTransition when the observer must cover cubits.

for a principal

Define which hook a team's cross-cutting concerns use, so logging, analytics and persistence stay consistent across blocs and cubits.

## Two layers of hooks The bloc package offers each lifecycle hook twice: - **per instance** - `onEvent`, `onTransition`, `onChange`, `onError`, `onDone` as methods you can override on your own `Bloc` or `Cubit`; - **globally** - the same names on the app-wide `BlocObserver`, assigned to `Bloc.observer`. The per-instance method is the one the framework calls; its base implementation forwards to the observer. That is why the instance methods are `@mustCallSuper`: an override that forgets `super.onChange(change)` quietly cuts that bloc off from the global observer - and from anything else built on `onChange`, such as hydrated_bloc's persistence. ## The order for one handled event From the bloc 9 source, adding one event whose handler emits one new state runs: 1. **`onEvent(event)`** - synchronously inside `add`, before the event reaches the handler. 2. The handler runs (asynchronously, after the event is delivered). It calls `emit(next)`. 3. **`onTransition(Transition(currentState, event, nextState))`** - the bloc-specific record tying the event to the change. 4. **`onChange(Change(currentState, nextState))`** - the generic record every `BlocBase` produces. 5. The state is updated and pushed to `stream`, so `BlocBuilder`s rebuild. 6. **`onDone(event)`** - when the handler's future completes; if the handler threw, `onError` fires first and `onDone` receives the error and stack trace. A handler that emits twice produces two transition-plus-change pairs and one `onDone`. ## What differs for a Cubit A `Cubit` changes state through its own methods calling `emit`. There is no event, so: | Callback | Bloc | Cubit | |---|---|---| | `onEvent` | on every `add` | never | | `onTransition` | on every emit from a handler | never | | `onChange` | on every emit | on every emit | | `onDone` | when a handler finishes | never | | `onError` | reported errors | reported errors | This is why a logging observer that only overrides `onTransition` shows nothing for cubits: `onChange` is the callback that covers both. ## Before the state updates The documentation of both `onChange` and `onTransition` says they are called before the state is updated. In the source, `emit` calls `onChange` and only then assigns the new state. Practical consequences: - inside the callback, use `change.currentState` and `change.nextState`, not `bloc.state`, to see what is happening; - an exception thrown from an override interrupts that emit - the state is not updated. ## When nothing fires - **Equal state.** If the emitted state `==` the current one and the instance has emitted before, the emit is dropped before `onTransition` or `onChange`. (The very first emission is allowed even when equal.) - **Closed bloc.** Emitting from a handler after `close()` is ignored; calling `emit` directly on a closed instance throws a `StateError`, which is reported through `onError`. ## Why the order matters in interviews The order explains real bugs: - analytics in `onChange` that reads `bloc.state` logs the previous value; - a cubit with a custom `onChange` that skips `super` stops being logged and, if it is hydrated, stops being persisted; - a transition log that never shows a cubit is not broken - cubits have no transitions.

  • Inside BlocObserver.onChange, what does bloc.state return?
    The previous state. `onChange` runs before the new state is assigned, so read `change.nextState` for the value being emitted and `change.currentState` for the one it replaces.
  • Why can skipping super.onChange in a HydratedCubit stop persistence?
    hydrated_bloc persists inside its own `onChange` override, which runs through the `super` chain. An override on your cubit that omits `super.onChange(change)` never reaches it, so nothing is written - and the global observer sees nothing either.

saying these in an interview costs you the question

  • Expects onTransition logs from a Cubit.
  • Believes onChange runs after the state has already been updated.
  • Thinks emitting an equal state still triggers onChange.
  • Overrides onChange on a bloc without calling super.onChange.
  • Places onEvent after the handler has emitted its states.