Dart Intermediate Lesson 64 of 115 5 min read
Immutable Classes and copyWith in Dart
Write immutable Dart classes: final fields, const constructors, unmodifiable lists, the copyWith pattern, value equality, and when to use records or freezed.
On this page
An immutable object cannot change after it is created. To “change” it, you make a new object with the new values. This sounds wasteful, but it makes code much easier to reason about, and it is how state works in Flutter, Riverpod and Bloc.
The problem with mutable objects
class Cart {
List<String> items;
Cart(this.items);
}
void main() {
final saved = Cart(['apple']);
final current = saved; // the same object, not a copy
current.items.add('mango');
print(saved.items); // the "saved" cart changed too!
}
[apple, mango]
Two variables pointed at one object, so a change through one was visible through the other. Bugs like this are hard to find in a big app.
Step 1: final fields and a const constructor
class Point {
final int x;
final int y;
const Point(this.x, this.y);
}
void main() {
const p = Point(1, 2);
// p.x = 5; // compile error: x is final
print('${p.x}, ${p.y}');
}
1, 2
final stops the fields from being reassigned. A const constructor lets Dart create the object at compile time and reuse it. See const constructors.
Step 2: protect lists and maps
final only protects the variable. A final List can still be changed with add. Make a private, unmodifiable copy:
class Cart {
final List<String> items;
Cart(List<String> items) : items = List.unmodifiable(items);
}
void main() {
final source = ['apple'];
final cart = Cart(source);
source.add('mango'); // does not affect the cart
print(cart.items);
try {
cart.items.add('kiwi');
} on UnsupportedError {
print('The cart cannot be changed directly');
}
}
[apple]
The cart cannot be changed directly
Step 3: the copyWith pattern
copyWith returns a new object, copying every field you do not pass:
class User {
final String name;
final int age;
final String city;
const User({required this.name, required this.age, required this.city});
User copyWith({String? name, int? age, String? city}) {
return User(
name: name ?? this.name,
age: age ?? this.age,
city: city ?? this.city,
);
}
@override
String toString() => 'User($name, $age, $city)';
}
void main() {
const asha = User(name: 'Asha', age: 21, city: 'Pokhara');
final older = asha.copyWith(age: 22);
final moved = older.copyWith(city: 'Toronto');
print(asha);
print(older);
print(moved);
}
User(Asha, 21, Pokhara)
User(Asha, 22, Pokhara)
User(Asha, 22, Toronto)
The original asha is untouched. This is exactly how you update state in Flutter: state = state.copyWith(loading: true);.
copyWith with a list
class Cart {
final List<String> items;
const Cart([this.items = const []]);
Cart copyWith({List<String>? items}) => Cart(items ?? this.items);
Cart add(String item) => copyWith(items: [...items, item]);
Cart remove(String item) => copyWith(items: items.where((i) => i != item).toList());
}
void main() {
const empty = Cart();
final one = empty.add('apple');
final two = one.add('mango');
final back = two.remove('apple');
print(empty.items);
print(two.items);
print(back.items);
}
[]
[apple, mango]
[mango]
Every method returns a new cart, built with the spread operator and where.
Setting a nullable field back to null
name ?? this.name cannot tell “not passed” from “set to null”. If a field is nullable and you need to clear it, pass a function instead:
class Task {
final String title;
final DateTime? due;
const Task(this.title, {this.due});
Task copyWith({String? title, DateTime? Function()? due}) =>
Task(title ?? this.title, due: due != null ? due() : this.due);
}
void main() {
final t = Task('Pay rent', due: DateTime(2026, 11, 1));
final cleared = t.copyWith(due: () => null);
print(t.due != null);
print(cleared.due);
}
true
null
Step 4: value equality
Two immutable objects with the same values should usually be equal. By default Dart compares identity, so add == and hashCode:
class Point {
final int x;
final int y;
const Point(this.x, this.y);
Point copyWith({int? x, int? y}) => Point(x ?? this.x, y ?? this.y);
@override
bool operator ==(Object other) => other is Point && other.x == x && other.y == y;
@override
int get hashCode => Object.hash(x, y);
}
void main() {
final a = Point(1, 2);
final b = a.copyWith();
print(identical(a, b)); // different objects
print(a == b); // but equal values
}
false
true
Read equality and hashCode for the details.
Shortcuts: records, Equatable and freezed
Writing copyWith, ==, hashCode and toString by hand gets repetitive. Options:
| Option | Best for |
|---|---|
Records (x: 1, y: 2) | Small groups of values. Immutable with value equality built in, but no copyWith. |
equatable package | Adds == and hashCode from a list of fields. Popular with Bloc. |
freezed package | Generates copyWith (including setting null), equality, toString, JSON and sealed unions. Popular with Riverpod. |
@immutable from meta | The analyzer warns you if a class has a non-final field. Flutter’s foundation.dart exports it. |
A freezed class looks like this (it needs build_runner to generate the code):
// Not standalone: needs freezed, freezed_annotation and build_runner.
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
@freezed
abstract class User with _$User {
const factory User({required String name, required int age}) = _User;
}
Rules for immutable classes
- Make every field
final. - Add a
constconstructor when you can. - Copy incoming lists and maps with
List.unmodifiable/Map.unmodifiable. - Change things with
copyWith(or methods that return a new object), never setters. - Override
==andhashCode, or use a record,equatableorfreezed.
Try it yourself
Write an immutable BankAccount with owner and balance. Add deposit(amount) and withdraw(amount) methods that return a new account, and make withdraw throw an ArgumentError when the balance would go below zero.
What does copyWith do in Dart?
copyWith is a method you write on an immutable class. It returns a new object with the same values as the old one, except for the fields you pass in. The original object is never changed.
Why does Flutter use immutable classes?
Widgets and state objects in Riverpod, Bloc and Provider are compared to decide what to rebuild. With immutable objects, a change always produces a new object, so the change is easy to detect and no other code can modify your data by surprise.