skip to content

When a Flutter state class holds nested collections, how do listEquals, ListEquality and DeepCollectionEquality differ in what they compare?

level: seniorimportance: should knowfreq 40%

answer

  1. one level versus every level
  2. element == decides for shallow
  3. nested lists compare by identity
  4. DeepCollectionEquality recurses
  5. equals needs a matching hash

basics

~20 s

Flutter's listEquals and package:collection's ListEquality compare two lists element by element using each element's ==, so nested lists inside are compared by identity; DeepCollectionEquality recurses into nested lists, sets, maps and iterables and offers a matching hash.

solid answer

~40 s

Dart `List` does not override `==`, so two lists with the same contents are unequal. The helpers fill that gap at different depths. Flutter's `listEquals` (in `package:flutter/foundation.dart`) and `ListEquality` from `package:collection` compare length and then each pair of elements with `==`: correct for a list of strings or of value objects, but a `List<List<int>>` still compares its inner lists by identity. `DeepCollectionEquality` recurses: nested lists and iterables in order, sets and maps by content. It also provides `hash`, which matters because an `==` override needs a consistent `hashCode`. freezed's generated `==` and `hashCode` use `const DeepCollectionEquality().equals` and `.hash` on fields; hand-written state classes should do the same.

code

dart · 24 lines
dart
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';

@immutable
class CartState {
  const CartState(this.lines);
  final List<List<String>> lines;

  static const _deep = DeepCollectionEquality();

  @override
  bool operator ==(Object other) =>
      other is CartState && _deep.equals(other.lines, lines);

  @override
  int get hashCode => _deep.hash(lines);
}

void main() {
  final a = CartState([['tea', '2']]);
  final b = CartState([['tea', '2']]);
  print(listEquals(a.lines, b.lines)); // false: inner lists by identity
  print(a == b); // true: deep comparison
}

go deeper

for a junior

Recall that two lists with the same contents are not == in Dart, and that listEquals compares them element by element.

for a middle

Explain one-level versus recursive comparison with a nested list example, and name which helper comes from Flutter and which from package:collection.

for a senior

Write consistent == and hashCode pairs for state classes, read what freezed generates, and weigh deep equality cost against identity checks on immutable state.

for a principal

Set a policy for state equality across the app, such as generated value types everywhere, so change detection is uniform and not hand-rolled per class.

## Why a helper is needed at all In Dart, `List`, `Set` and `Map` do not override `==`, so `[1, 2] == [1, 2]` is `false`: two separate objects. That matters in Flutter state classes, where "did the state change?" is usually answered with `==`, and a new list with the same contents would otherwise count as a change. (Why `==` defaults to identity is a separate topic; here the question is which content comparison to use.) ## Shallow: `listEquals` and `ListEquality` **`listEquals<T>(a, b)`** lives in Flutter's `foundation` library. It returns `true` when: - both are `null`, or - both are non-null, have the same length, and each pair of elements is `==`. `setEquals` and `mapEquals` do the same for sets and maps. Their own documentation says that element collections are **not** compared element by element unless their `==` does so, and points to `DeepCollectionEquality` for deep checks. **`ListEquality`** from `package:collection` does the same job outside Flutter: `const ListEquality<String>().equals(a, b)` compares element by element with the elements' `==` by default, and its `hash` method produces a hash consistent with that comparison. Both are **one level deep**: ```dart listEquals(['a', 'b'], ['a', 'b']); // true listEquals([[1], [2]], [[1], [2]]); // false: inner lists by identity ``` ## Deep: `DeepCollectionEquality` `DeepCollectionEquality` from `package:collection` **recurses**: 1. two lists or iterables are equal when their elements are deeply equal in order; 2. two sets or two maps are equal when their contents are deeply equal, whatever the iteration order; 3. anything else falls back to `==`. So `const DeepCollectionEquality().equals([[1], [2]], [[1], [2]])` is `true`. Its `hash` method walks the same structure and returns a hash consistent with `equals`. | Helper | Package | Depth | Hash helper | |---|---|---|---| | `listEquals` / `setEquals` / `mapEquals` | `flutter/foundation` | one level | none | | `ListEquality` | `collection` | one level | `hash` | | `DeepCollectionEquality` | `collection` | all levels | `hash` | | `Object.hashAll` | `dart:core` | one level, by element `hashCode` | is the hash | ## Keeping `==` and `hashCode` consistent If you override `==` with content equality, `hashCode` must agree: equal objects must have equal hash codes, or sets and map keys misbehave. Pair the helpers accordingly: - shallow `==` via `listEquals` or `ListEquality` goes with `Object.hashAll(items)` or `ListEquality().hash(items)`; - deep `==` via `DeepCollectionEquality().equals` goes with `DeepCollectionEquality().hash`. Mixing a deep `==` with `items.hashCode` (identity-based) breaks the contract. ## What generated code does freezed generates exactly this pairing: its `==` compares collection fields with `const DeepCollectionEquality().equals(other._items, _items)` and its `hashCode` uses `const DeepCollectionEquality().hash(_items)`. That is why two freezed states built from separate but equal lists compare equal. ## Where Flutter code needs these comparisons Content equality on collections shows up in a few recurring places: - a `State.didUpdateWidget` that must decide whether a new `items` list on the widget really differs from the old one before redoing expensive work; - a `CustomPainter.shouldRepaint` or an `InheritedWidget.updateShouldNotify` that compares list fields of the old and new instance; - `==` on state objects consumed by state-management code that skips rebuilds for equal states. In each case, returning `true` for "changed" when only the list identity changed causes extra work; returning `false` when a nested value changed hides a real update. Pick the depth that matches what the data can contain. ## Choosing, and the cost - For a flat list of immutable values, a shallow helper is enough and cheaper. - For nested collections, or when element types do not override `==`, deep equality is the only content comparison that works. - Deep equality walks the whole structure on every comparison: O(total elements). For large states compared on every rebuild, prefer immutable collections replaced on change, so that an identity check (`identical`) already tells you whether anything changed. - Deep equality stops at objects: a nested custom class is compared with its own `==`, which must itself be content-based.

  • Why must a Dart class that uses DeepCollectionEquality in == also use its hash?
    Equal objects must have equal hash codes. A list's own `hashCode` is identity-based, so two deeply equal states would hash differently and break `Set` membership and map keys. `DeepCollectionEquality().hash` walks the same structure as `equals`, keeping the two consistent.
  • When is identity comparison a better change check than deep equality in Flutter state?
    When the state is immutable and every change produces new collections. Then `identical(old.items, new.items)` answers whether anything changed in O(1), while deep equality costs a full walk on every comparison. Deep equality is the fallback for states rebuilt with fresh but possibly equal lists.

Shallow equality is checking that two bookshelves hold the same boxes in the same order by their labels; deep equality opens every box, and every box inside it, to compare what is actually inside.

saying these in an interview costs you the question

  • Flutter's listEquals compares nested lists by their contents
  • Two Dart lists with the same elements are == to each other
  • Overriding == with deep equality needs no matching hashCode change
  • DeepCollectionEquality is cheap enough to run on huge states every frame
  • Deep equality also compares custom objects field by field automatically