skip to content

A flutter_bloc water-intake Cubit appends a drink to a List inside its Equatable state and emits, but BlocBuilder does not rebuild. What is wrong?

level: seniorimportance: should knowfreq 46%

answer

  1. old and new share one list
  2. Equatable compares lists element-wise
  3. equal to current: emit drops it
  4. copy with spread or List.of
  5. every field in props

basics

~20 s

The Cubit mutated the list the current state already holds, so after any earlier emit the new state's props equal the current ones and emit drops it. Emit a state built from a new list, such as [...state.drinks, drink].

solid answer

~40 s

`emit` drops a state that is `==` to the current one, and an `Equatable` state compares its runtime type and `props`, walking a `List` element by element. `state.drinks.add(drink)` mutates the list the *current* state already holds, so when the Cubit emits a new state built from `state.drinks`, both states see the same updated list and compare equal; after the first emit that means no `onChange`, no stream event, no rebuild. The fix is never to mutate: emit a state built from `[...state.drinks, drink]` or `List.of(state.drinks)..add(drink)`, and store the list with `List.unmodifiable` so a stray `add` throws immediately. Check the symptom's other causes too: a field missing from `props`, or `emit(state)` after changing a field.

code

dart · 25 lines
dart
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';

class BuggyIntakeState extends Equatable {
  const BuggyIntakeState({required this.drinks, required this.dailyLimitMl});

  final List<int> drinks; // final field, but the list itself is mutable
  final int dailyLimitMl;

  @override
  List<Object?> get props => [drinks, dailyLimitMl];
}

class BuggyIntakeCubit extends Cubit<BuggyIntakeState> {
  BuggyIntakeCubit()
      : super(BuggyIntakeState(drinks: <int>[], dailyLimitMl: 2000));

  void addDrink(int millilitres) {
    state.drinks.add(millilitres); // mutates the current state's list
    emit(BuggyIntakeState(
      drinks: state.drinks,
      dailyLimitMl: state.dailyLimitMl,
    )); // equal to the current state after the first emit: dropped
  }
}

go deeper

for a junior

Recall that Cubit states must be treated as immutable snapshots: build a new list for every change instead of calling add on the current one.

for a middle

Explain the chain: in-place mutation, shared list, Equatable's element-wise props comparison, and emit dropping an equal state.

for a senior

Diagnose it quickly from the symptom, list the sibling causes such as missing props, and harden states with unmodifiable collections and tests on exact emitted states.

for a principal

Treat it as a convention problem: immutable state types, review checks for props completeness and a lint or test baseline that keeps the whole codebase from reintroducing it.

## The symptom A water-intake tracker built with the **bloc** and **flutter_bloc** packages keeps a list of drinks and a daily limit in an `Equatable` state. Tapping add drink calls the Cubit, the Cubit calls `emit`, a debugger shows the list growing — yet the `BlocBuilder` never rebuilds and a `BlocObserver` logs no change. The Cubit did emit; `emit` threw the state away. ## Root cause, step by step 1. The current state holds list `L`. 2. The method runs `state.drinks.add(drink)`, mutating `L` in place. The current state now already contains the new drink. 3. The method emits a new state constructed with `drinks: state.drinks` — which is `L` again. 4. `emit` evaluates `newState == currentState`. Equatable checks the runtime type (same class), then compares props: `[L, 2000]` against `[L, 2000]`. The lists are the very same object, so they are equal. 5. Because something was emitted before (a load, an earlier drink), `emit` returns early. Nothing is published. Only the very first emit of a Cubit's life is exempt from this check. Dart's `final` does not help: it stops the field from being reassigned, not the list it points to from being mutated. The same drop happens with any value-equal type, and without Equatable if the Cubit calls `emit(state)` on the same mutated instance, because an object is always `==` to itself. ## Other causes of the same symptom | Cause | Why emit drops it | Fix | |---|---|---| | Mutated shared collection | old and new props hold the same list | copy, never mutate | | Field missing from `props` | the changed field is invisible to `==` | list every distinguishing field | | `emit(state)` after changing a field | same instance is `==` to itself | emit a new instance via `copyWith` | ## The fix ```dart void addDrink(Drink drink) { if (state.totalMl + drink.millilitres > state.dailyLimitMl) return; emit(IntakeState( drinks: [...state.drinks, drink], dailyLimitMl: state.dailyLimitMl, )); } ``` The new state now holds a new list with one more element. Equatable compares the lists element by element, finds a different length, and `emit` publishes the state. ## Making the bug impossible - Wrap collections with `List.unmodifiable` (or `Map.unmodifiable`, `Set.unmodifiable`) in the state's constructor. A stray `add` then throws `UnsupportedError` at the call site instead of silently corrupting state. A `const []` default behaves the same way. - Keep every field `final`; Equatable is annotated `@immutable`, so the analyzer flags a mutable field. - Give the state a `copyWith` that builds new collections rather than passing the old ones through. - Put every distinguishing field in `props`. - Cover the method with a test that expects the exact emitted states; a dropped emit fails it at once. ## Why removing Equatable is not the fix Deleting `extends Equatable` makes rebuilds return, because `==` becomes identity and every new instance passes. But the mutation is still there: - The `Change` handed to `onChange` has a `currentState` that already contains the new drink, so logs and any diffing or undo logic see the wrong history. - Every no-op emit now rebuilds the UI. - Tests can no longer compare expected instances directly. The root problem is shared mutable state inside something designed to be an immutable snapshot. Copy on every change and the equality check becomes an ally rather than a trap.

  • Why does removing Equatable make rebuilds come back, and why is that not a fix?
    Without Equatable, `==` is identity, so any new state instance passes emit's check. But the previous state's list was still mutated: the `Change` given to `onChange` shows a `currentState` that already contains the new drink, history-based logic reads the wrong data, and every no-op emit now rebuilds. Copy instead of mutating.
  • What does List.unmodifiable in the state constructor buy you?
    It turns a silent bug into a loud one: `add`, `remove` or an index assignment on `state.drinks` throws `UnsupportedError` at the offending line, so the mutation surfaces in the first test run. It costs one copy per state, which is negligible for UI-sized lists.
  • Does Equatable compare the Drink objects inside the list by value?
    Yes, when the elements support it: Equatable walks the list and compares each pair with the element's `==`. `Drink` extends Equatable, so two lists of equal-millilitre drinks are equal. With a plain `Drink` class, two lists holding distinct but identical-looking instances would compare unequal.

saying these in an interview costs you the question

  • A final field makes the list inside the state immutable
  • Equatable compares lists by reference, so mutation should trigger a rebuild
  • Calling setState in the widget after the Cubit emits fixes it
  • Removing Equatable is the right fix because rebuilds come back
  • BlocBuilder caches the state, so the Cubit must be recreated