copyWith Pattern
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
/// nullWhat's happening here?
- The original
carDataobject stays unchanged. copyWithreturns a newCarModelinstance.- 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
-
How does
copyWithinteract with==andhashCode? 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. AddEquatableorfreezedfor value equality and identical copies short-circuit the notification. -
How do you let
copyWithset a nullable field tonull? The?? this.fieldfallback can't tell "not passed" from "passed as null", socopyWith(nickname: null)behaves likecopyWith(). Use a sentinelObjectdefault (as in theCarModelabove), or reach forfreezed/copy_with_extension, which handle it for you.
How helpful was this content?
Please sign in to rate this article.