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.


Generating copyWith

Writing copyWith methods for deep nesting or large models can cause heavy boilerplate clutter. Most production apps delegate this task to code generation packages:

  • Freezed: The gold-standard solution for immutable data classes that generates copyWith, union types, and cloning mechanisms automatically.
  • copy_with_extension: A lightweight alternative dedicated solely to creating advanced copy macros.

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.