copyWith for Immutable State Updates

Medium PriorityAsked in ~60% of mid-level interviews

4 min read

State Management

copyWith is a Dart convention for cloning an immutable object with one or two fields swapped out. The implementation is boring on purpose: every field becomes an optional named parameter, and if the caller doesn't pass one, it falls back to this.field via ??. Call state.copyWith(isLoading: true) and you get a new instance where isLoading is true and every other field is carried over unchanged.

That pattern lines up almost too neatly with modern state management. In BLoC, Riverpod and Provider, state classes are usually immutable (all final fields), so you can't mutate a property to change something, you have to emit a new instance. copyWith gives you the "change one field, keep the rest" ergonomics without writing out the full constructor each time. Pair the class with value equality (Equatable or freezed) and copies with identical contents compare equal, which lets the framework skip rebuilds when nothing meaningful changed.


Hand-written example

class CartState {
  final List<Item> items;
  final bool       isLoading;
  final String?    error;   // nullable, see the sentinel note below

  const CartState({
    this.items = const [],
    this.isLoading = false,
    this.error,
  });

  // Sentinel so callers can pass null to actually clear `error`.
  static const _unset = Object();

  CartState copyWith({
    List<Item>? items,
    bool?       isLoading,
    Object?     error = _unset,
  }) {
    return CartState(
      items:     items     ?? this.items,
      isLoading: isLoading ?? this.isLoading,
      error:     identical(error, _unset) ? this.error : error as String?,
    );
  }
}
// Inside a Cubit, every "mutation" is a fresh emit.
class CartCubit extends Cubit<CartState> {
  CartCubit() : super(const CartState());

  void add(Item i)        => emit(state.copyWith(items: [...state.items, i]));
  void setLoading(bool v) => emit(state.copyWith(isLoading: v));
  void fail(String msg)   => emit(state.copyWith(error: msg));
  void clearError()       => emit(state.copyWith(error: null));  // works because of the sentinel
}

When to write it by hand vs generate it

ApproachGood fit
Hand-written copyWithSmall classes with 3 or 4 fields, no nullable field you need to clear
Equatable + hand-written copyWithYou want value equality without pulling in build_runner
freezed5+ fields, nullables you need to clear, sealed unions, JSON serialisation
built_valueLegacy codebases. On new work reach for freezed instead

Once a class has more than a handful of fields, hand-written copyWith becomes a place bugs hide (miss a field, and it silently resets on every copy). freezed also gives you ==, hashCode and toString in the same pass, so it usually pays for itself pretty quickly.


Common mistakes

// ❌ Naive impl can't clear a nullable field
CartState copyWith({String? error}) =>
    CartState(error: error ?? this.error, ...);
// copyWith(error: null) silently keeps the old error.
// ✅ Use a sentinel Object, a Value<T> wrapper, or freezed.

// ❌ Mutating the existing collection instead of making a new one
state.copyWith(items: state.items..add(x));
// Same list reference, so value equality sees no change and listeners don't rebuild.
// ✅ state.copyWith(items: [...state.items, x])

// ❌ Adding a new field to the class and forgetting to add it to copyWith
// Silent bug: the field resets to its default on every copy.
// ✅ Use freezed. The generator can't forget.

// ❌ Chaining copyWith calls to fake a reducer
state.copyWith(a: 1).copyWith(b: 2).copyWith(c: 3);
// Three allocations for no reason. Do it in one call.

// ❌ Nested copyWith on deep object graphs
state.copyWith(user: state.user.copyWith(address: state.user.address.copyWith(...)));
// Painful and hard to read. Flatten the state, or split into sub-cubits.

Interview follow-ups

  1. How does copyWith interact with == and hashCode? On its own, not at all. copyWith just builds a new instance, so unless the class overrides equality two copies with identical fields still compare unequal. In a state class that's usually a bug: every emit looks like a change, every listener rebuilds, and you start chasing "why does this widget rebuild on every keystroke". The fix is to add value equality with Equatable or freezed so identical copies short-circuit the notification.

  2. How do you let copyWith set a nullable field to null? The ?? this.field fallback can't tell "not passed" from "passed as null", so copyWith(error: null) ends up doing the same thing as copyWith(). Two ways out without code generation: a sentinel Object used as the default value (like the example above), or a small Value<T> wrapper that boxes "unset" vs "set to X". If you're already on freezed, it handles this for you and copyWith(error: null) just works.


How helpful was this content?

Please sign in to rate this article.