copyWith Pattern

Medium PriorityAsked in ~60% of mid-level interviews

4 min read

State Management

copyWith is a method convention used to duplicate an immutable object while modifying specific properties. Because state objects in modern Flutter architectures (like BLoC, Riverpod, or Provider) are typically immutable (final fields), you cannot mutate their properties directly. Instead, copyWith clones the existing instance, substitutes the newly provided arguments, and falls back to the original values via the null-aware operator (??) for any parameters left unspecified.


Implementing copyWith

class CarModel {
  final String brand;
  final String model;
  final int year;
  final bool isElectric;
  final String? nickname; // nullable, see the sentinel trick below
 
  const CarModel({
    required this.brand,
    required this.model,
    required this.year,
    required this.isElectric,
    this.nickname,
  });
 
  // Sentinel so callers can pass null to actually clear `nickname`.
  static const _unset = Object();
 
  CarModel copyWith({
    String? brand,
    String? model,
    int? year,
    bool? isElectric,
    Object? nickname = _unset,
  }) {
    return CarModel(
      brand: brand ?? this.brand,
      model: model ?? this.model,
      year: year ?? this.year,
      isElectric: isElectric ?? this.isElectric,
      nickname: identical(nickname, _unset) ? this.nickname : nickname as String?,
    );
  }
}

Why the sentinel? A classic issue with the basic copyWith setup is that you cannot explicitly pass null to reset a value, because null ?? this.field will default back to the original value. Using a private _unset object as the default lets you compare with identical(...) and treat explicit null differently from not passed.

Using copyWith

void main() {
  final carData = CarModel(
    brand: 'Tesla',
    model: 'Model 3',
    year: 2022,
    isElectric: true,
    nickname: 'Sparky',
  );
 
  /// UPDATE ONLY THE YEAR
  final updatedCar = carData.copyWith(year: 2024);
  print(updatedCar.year); // 2024
 
  /// UPDATE MULTIPLE FIELDS
  final newCar = carData.copyWith(
    model: 'Model Y',
    year: 2025,
  );
  print('CAR DETAILS: ${newCar.model} - ${newCar.year}');
 
  /// CLEAR A NULLABLE FIELD (uses the sentinel)
  final anonymousCar = carData.copyWith(nickname: null);
  print(anonymousCar.nickname); // null
}
 
/// OUTPUT
/// 2024
/// CAR DETAILS: Model Y - 2025
/// null

What's happening here?

  • The original carData object stays unchanged.
  • copyWith returns a new CarModel instance.
  • Only the fields you pass in are updated.
  • Every other value is reused from the original.

This pattern makes sure data changes don't mutate existing objects, which keeps state flows predictable.

Integrating with state management

🔹 BLoC / Cubit: put copyWith on your state class so every new state you emit is a fresh immutable instance. The cubit never touches the previous state, it builds a new one and hands it to emit, which is exactly what BLoC relies on to detect that something changed.


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
copy_with_extensionYou already have plain Dart classes and just want a generated copyWith (plus copyWithNull for clearing nullables) via a @CopyWith() annotation, without adopting the full freezed toolkit
freezed5+ fields, nullables you need to clear, sealed unions, JSON serialisation, and you're happy to let it own the whole class

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). If you only need the copyWith bit and want to keep your existing class shape, copy_with_extension is the low-friction option. If you also want ==, hashCode, toString, sealed unions and JSON support in one shot, freezed usually pays for itself pretty quickly.


Common mistakes

// ❌ Naive impl ignores `copyWith(nickname: null)`
CarModel copyWith({String? nickname}) =>
    CarModel(nickname: nickname ?? this.nickname, ...);
// The old nickname stays, because `?? this.nickname` fires on null.
// ✅ Sentinel default (see the CarModel above), Value<T> wrapper, or freezed.
 
// ❌ Mutating a collection field instead of building a new one
// (say CarModel also had `List<String> tags`)
carData.copyWith(tags: carData.tags..add('EV'));
// Same list reference, so value equality sees no change and listeners skip the rebuild.
// ✅ carData.copyWith(tags: [...carData.tags, 'EV'])
 
// ❌ Adding a field to the class and forgetting to update copyWith
// The new field silently resets to its default on every copy.
// ✅ Let copy_with_extension or freezed generate it. The generator can't forget.
 
// ❌ Chaining copyWith calls to fake a reducer
carData.copyWith(brand: 'Rivian').copyWith(model: 'R1S').copyWith(year: 2025);
// Three throwaway allocations. Do it in one call:
carData.copyWith(brand: 'Rivian', model: 'R1S', year: 2025);
 
// ❌ Nested copyWith on deep object graphs
// (say CarModel had an `owner` with a nested `address`)
carData.copyWith(owner: carData.owner.copyWith(address: carData.owner.address.copyWith(...)));
// Painful to read and easy to break. Flatten the model, or lean on freezed's nested copyWith.

Interview follow-ups

  1. How does copyWith interact with == and hashCode? Not at all on its own. Unless the class overrides ==, two copies with identical fields still compare unequal, so every emit looks like a change and every listener rebuilds. Add Equatable or freezed for value equality and 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(nickname: null) behaves like copyWith(). Use a sentinel Object default (as in the CarModel above), or reach for freezed / copy_with_extension, which handle it for you.


How helpful was this content?

Please sign in to rate this article.