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.
When to write it by hand vs generate it
| Approach | Good fit |
|---|---|
Hand-written copyWith | Small classes with 3 or 4 fields, no nullable field you need to clear |
Equatable + hand-written copyWith | You want value equality without pulling in build_runner |
copy_with_extension | You 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 |
freezed | 5+ 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
-
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.