Immutable data constructs are those that cannot be mutated (altered) after they've been initialized. The Dart language is full of these. In fact, most basic variable types operate this way. Once created, strings, numbers, and boolean values cannot be mutated. A string variable doesn't contain string data, itself. It is a reference to the string data's location in memory. Non-final string variables can be reassigned, which points them to new string data, but once created, string data itself doesn't change content or length:
var str = "This is a string.";
str = "This is another string.";
This code declares a string variable (typed by inference) called str
. The quotes create a string literal, with the characters between them comprising the string data. The data is placed in memory, then a reference to its location is stored in the variable str
. The second line creates an all-new string and assigns a reference to its memory location to the same variable, overwriting the reference to the first string. The original string is not changed, but if there is no longer a valid reference to it in your code, it is marked as unreachable, and its memory will eventually be freed by Dart's garbage collector.
There are a number of advantages to using immutable data. It's inherently thread safe, because since no code can alter its content, it's guaranteed to be the same no matter what code is accessing it. You can safely pass it around by reference, without needing strategies like defensive copying to keep the data from changing in unexpected ways. Projects using immutable data can be simpler and easier to reason about, since there is no need for convoluted and complicated code to manage every possible state permutation.
We'll begin our discussion of Dart's built-in immutability features by looking at the final
and const
keywords, two subtly different ways to declare data that shouldn't mutate.
The code for this article was tested with Dart 2.16.2 and Flutter 2.10.4.
Final variables vs. constants
The distinction between Dart's final
and const
keywords can be fuzzy for beginners. When creating your own immutable data, it's important to understand how they're different and where to use each.
A final variable allows only a single assignment. It must have an initializer, and once it has been initialized with a value, the variable cannot be reassigned:
final str = "This is a final string.";
str = "This is another string."; // error
Dart will not allow you to change the value of the final variable str
. A final variable may rely on runtime execution of code to determine its state, but it must occur during initialization. Other than disallowing reassignment, a final variable acts like a regular variable in every way.
Constants in Dart are compile-time constants. The const
keyword modifies values. A constant's entire deep state must be determinable at compile time. A constant value cannot be dependent on runtime code execution to resolve its state. The constant value will be frozen and immutable while the program is running.
Dart constants share three main properties:
- Constant values are deeply, transitively immutable. If you want to create a constant collection (list, map, etc.), every element must also be constant, recursively.
- Constant values have to be created from data available at compile time. For instance,
DateTime.now()
cannot be constant, because it relies on data only available at runtime to create itself. A SizedBox in Flutter has all final properties and a constant constructor, so it can be a constant:const SizedBox(width: 10 + 10)
. Everything needed to construct that instance is there in the code. The Dart compiler can perform simple math operations or string concatenations during compilation. - Constants are canonicalized. For any given constant value, a single object is created in memory regardless of how many times the constant expression is evaluated. The constant object is reused when needed, but never recreated.
A few constant examples:
const str = "This is a constant string.";
const SizedBox(width: 10); // a constant object
const [1, 2, 3]; // a constant collection
1 + 2; // a constant expression
The str
constant is assigned a string literal, which are always compile-time constants. The SizedBox instance created here can be constant and immutable because Dart is able to set it up before executing the program, since all of the properties of SizedBox are final internally and we're passing a literal argument (10
). The constant list literal works here because every element is also constant. The expression 1 + 2
can be calculated by the Dart compiler before executing the code, so it also qualifies as constant.
Because constants are canonicalized and Dart compares identity by default, two seemingly separate instances of a constant will compare as equal, because they reference the exact same object in memory:
List<int> get list => [1, 2, 3];
List<int> get constList => const [1, 2, 3];
var a = list;
var b = list;
var c = constList;
var d = constList;
print(a == b); // false
print(c == d); // true
Even though a
, b
, c
, and d
each reference a list with identical content, only the constant versions compare as true
. Dart is comparing the memory address (reference) of the list, not the values of the elements. Each call to the constList
getter returns a reference to a constant list, but remember that Dart only puts the list into memory once, so the getter is always returning the same reference.
Next, we'll look at how the Flutter framework takes advantage of immutable data.
Immutable data in Flutter
There are many places where a Flutter application can make use of immutable structures to improve readability or performance. Lots of framework classes have been written to allow them to be constructed in an immutable form. Two common examples are SizedBox and Text:
Row(
children: [
const Text("Hello"),
const SizedBox(width: 10),
const Text("Hello"),
const SizedBox(width: 10),
const Text("Can you hear me?"),
],
)
This Row has been constructed with five children. When we use the const
keyword to create instances of classes that have const
constructors (more on those later), the values are created at compile time and each unique value is stored in memory just once. The first two Text instances will resolve to references to the same object in memory, as will the two SizedBox instances. If we were to add const SizedBox(width: 15)
, a separate constant instance would be created for that new value.
You can create all these instances without const
, which will imply new
, and the code will appear to work identically, but it's a best practice to use const
whenever you can to reduce your app's memory footprint and increase runtime performance.
The Row example can also be improved by making the whole list constant, which can save you some typing. This is only possible when every element is a valid constant:
Row(
children: const [
Text("Hello"),
SizedBox(width: 10),
Text("Hello"),
SizedBox(width: 10),
Text("Can you hear me?"),
],
)
Let's look at another Text example:
final size = 12.0;
const Text(
"Hello",
style: TextStyle(
fontSize: size, // error
),
)
This code snippet has a lot going on. We are trying to create a constant instance of Text, but remember that a valid constant is constant all the way down. The string literal "Hello"
works fine. Dart will try to create the TextStyle as a constant, even though we've left off the keyword, because it knows TextStyle will need to be constant to be part of the constant Text instance. TextStyle can't be constant here due to its reliance on the variable size
, which doesn't have a value until runtime. The analyzer will flag size
as a problem. To fix this, you must replace size
with either a constant reference or a numeric literal such as 12.0
. Changing final
to const
in the declaration of size
would also do the trick.
Sometimes you need to keep your app's state data from unexpectedly changing. Next, we'll look at ways to achieve this with Dart.
Creating your own immutable data classes
Creating a simple immutable class can be as easy as using final properties and adding const
to the constructor:
class Employee {
final int id;
final String name;
const Employee(this.id, this.name);
}
The Employee class has two properties, both declared final
, and these are initialized automatically by the constructor. The constructor uses the const
keyword to tell Dart it's okay to instantiate this class as a compile-time constant:
const emp1 = Employee(1, "Jon");
var emp2 = const Employee(1, "Jon");
final emp3 = const Employee(1, "Jon");
Only one constant instance of the Employee is created here, and each variable is assigned a reference to it. For emp1
, we don't need to include the const
keyword with the constructor, because its need is directly implied by our use of it on the variable, though you may include it if you wish. The emp2
variable is a regular variable with a type of Employee, but we've assigned it a reference to an immutable, constant object. The variable emp3
is identical to emp2
except that it can never be assigned a new reference. No matter where you pass these references, you can always be sure that when examined, the object's id
will be 1
and the name will be "Jon"
, and you'll always be examining the same values in memory.
Note that it's not typical to make final properties in data classes private. They can't be changed, and there often isn't much to be gained by restricting read access to them. If you do have some reason to conceal the value from prying code or if some internal state is irrelevant to users of the class, privacy might be worth considering.
Is there some way for Dart to help you understand when you've successfully created an immutable class? Read on.
Using a metatag helper
You can use the @immutable
metatag from the meta
package to get helpful analyzer warnings on classes you intend to be immutable:
import 'package:meta/meta.dart';
@immutable
class Employee {
int id; // not final
final String name;
Employee(this.id, this.name);
}
The metatag does not make your class immutable (if only it were that easy), but in this example, you will get a warning stating that one or more of your fields are not final
. If you try to add the const
keyword to your constructor while there are mutable properties, you'll get an error that tells you essentially the same thing. If a class has the @immutable
tag on it, any subclasses that aren't immutable will also have warnings.
There are a few property types that introduce some complexity when it comes to immutability: objects and collections. We'll look at how to handle these next.
Complex objects in immutable classes
What if an employee's name was represented by an object more complex than a string? As an example:
class EmployeeName {
String first;
String middleInitial;
String last;
EmployeeName(this.first, this.middleInitial, this.last);
}
So Employee would now look like this:
class Employee {
final int id;
final EmployeeName name;
const Employee(this.id, this.name);
}
For the most part, Employee works just like it did before, with one key difference. Since we haven't defined EmployeeName as an immutable class, its properties will be subject to change after initialization:
var emp = Employee(1, EmployeeName('John', 'B', 'Goode'));
emp.name = EmployeeName('Jane', 'B', 'Badd'); // blocked
emp.name.last = 'Badd'; // allowed
The name
property of Employee is final, so Dart prevents it from being reassigned. The properties of EmployeeName are not protected in the same way, however, so changing that data is allowed. If you were counting on your employee data to be immutable, this might be an unintended vulnerability. To solve this problem, make sure all classes used in your data composition are also immutable.
Immutable collections
Collections present another challenge to immutability. Even with a final
reference to a List or Map, the elements within those collections may still be mutable. Also, lists and maps in Dart are mutable complex objects themselves, so it can still be possible to add, remove, or reorder their elements.
Consider a simple example using chat message data:
class Message {
final int id;
final String text;
const Message(this.id, this.text);
}
class MessageThread {
final List<Message> messages;
const MessageThread(this.messages);
}
With this setup, the data is fairly safe. Every message created is immutable, and it's not possible to replace the list of messages inside MessageThread once it's been initialized. The list structure can be manipulated by outside code, though:
final thread = MessageThread([
Message(1, "Message 1"),
Message(2, "Message 2"),
]);
thread.messages.first.id = 10; // blocked
thread.messages.add(Message(3, "Message 3")); // Uh-oh. This works!
Probably not what you intend. So how do you prevent this? There are several different strategies available.
Return a copy of the collection
If you don't mind the calling code receiving a mutable copy of the collection, you can use a Dart getter to return a copy of the master list whenever it's accessed from outside the class:
class MessageThread {
final List<Message> _messages;
List<Message> get messages => _messages.toList();
const MessageThread(this._messages);
}
thread.messages.add(Message(3, "Message 3")); // new list
With this MessageThread class, the real message list is private. It can only be set once through the constructor. A getter named messages
is defined that returns a copy of the _messages
list. When outside code calls the list's add()
method, it is doing so on a separate copy of the list, so the original is not modified. The copied list will end up with a new message, but the list inside the MessageThread object will remain unaltered.
This approach is simple, but not without its downsides. First, with very large lists or frequent access, this could start to tax performance. A shallow copy of the list is made every time messages
is accessed. Second, it can be confusing for users of the class, as it may look to them like they're allowed to modify the original list. They may be unaware that a copy is being returned. This can lead to some surprising behavior in the app.
Return an unmodifiable collection or view
Another way to prevent changes to your collections within a data class is to use a getter to return an unmodifiable version or unmodifiable view:
class MessageThread {
final List<Message> _messages;
List<Message> get messages => List.unmodifiable(_messages);
const MessageThread(this._messages);
}
thread.messages.add(Message(3, "Message 3")); // exception!
This approach is very similar to the previously discussed approach. A copy of the list is still being made, but now the copy we're returning is unmodifiable. We use a factory
constructor defined on Dart's List class to create the new list. Now, when the user attempts to add a new message to their copy of the list, an exception is thrown at runtime, and the modification is prevented. Better, but some of the cons remain. There is no warning from the Dart analyzer that the call to add()
will fail at runtime, and the user is still receiving a copy of the list instead of a direct reference, which they may not be aware of.
We can improve on the approach a little, using the UnmodifiableListView class from the dart:collection
library:
import 'dart:collection';
class MessageThread {
final List<Message> _messages;
UnmodifiableListView<Message> get messages =>
UnmodifiableListView<Message>(_messages);
const MessageThread(this._messages);
}
thread.messages.add(Message(3, "Message 3")); // exception!
Doing it this way may perform a bit better, because an UnmodifiableListView does not create a copy of the original list. Instead, it wraps the original in a view that prevents modification. Unfortunately, violations are still reported only at runtime in the form of an exception, though a slightly more descriptive one than that provided by List.unmodifiable()
. Despite still having some shortcomings, this approach is very popular as a solution, as it is good enough for many situations.
What about other collection types? Other collections, such as Map and Set, also have anunmodifiable()
factory
constructor, and unmodifiable views for them are available in thedart:collection
library.
There are a few more things to consider when trying to prevent changes to your collections.
Truly immutable collections
You may have noticed that all of our tricks for returning immutable versions of collections with getters still leave the original collections technically mutable. Code within the library can manipulate the structure of the private _messages
list. This may be fine, but purists might want even that to be impossible.
One way to achieve this is to create our unmodifiable version or view as the MessageThread object is constructing:
class MessageThread {
final List<Message> messages;
const MessageThread._internal(this.messages);
factory MessageThread(List<Message> messages) {
return MessageThread._internal(List.unmodifiable(messages));
}
}
The first thing we need to do is hide the constant constructor from code outside the library. We change it into a named constructor with an underscore prefix, which makes it private. The MessageThread._internal()
constructor does the exact same job our old default constructor did, but it can only be accessed by internal code.
Then we make the default, public constructor a factory
constructor. Factories work a lot like static
methods, in that they must explicitly return an instance of the class instead of doing so automatically as regular constructors do. This is a useful difference, because here we need to make adjustments to the incoming list of messages before it's ready to be used as an initializer for our final
property. The factory constructor copies the incoming list into an unmodifiable list before passing it along to the private constructor, which creates the instance. Users are none the wiser, as they create instances the same way they always did:
final thread = MessageThread([
Message(1, "Message 1"),
Message(2, "Message 2"),
]);
This still works, and no one can tell (without peeking at the source) that they're calling a factory constructor instead of a regular one. Incidentally, this technique is like the one used for the Singleton design pattern in Dart.
Now that the stored list is unmodifiable, not even code within the same class or library can alter it. Not too many apps can do anything meaningful without data updates, though, so how do we update our immutable data safely?
Updating immutable data
Once you've got all your app state safely tucked away in immutable structures, you might be wondering how it can be updated. Individual instances of your classes shouldn't be mutable (at least from outside), but state certainly needs to change. As ever, there are a few different approaches, and we'll explore some of them here.
State update functions
One of the most common ways of updating immutable state is using some kind of state update function. In Redux, this can be a reducer, and there are similar constructs when using the BLoC pattern for state management. Wherever the update function resides, it's usually responsible for taking input, performing business logic, then outputting a new state based on the input and the old state.
Starting with the simplest example, let's look at a few possible state update functions for the immutable Employee class introduced earlier. Note that these functions are not part of the Employee class:
class Employee {
final int id;
final String name;
const Employee(this.id, this.name);
}
Employee updateEmployeeId(Employee oldState, int id) {
return Employee(id, oldState.name);
}
Employee updateEmployeeName(Employee oldState, String name) {
return Employee(oldState.id, name);
}
This pattern is easy, and it does a good job of making sure only supported updates are done. Basically, each function takes a reference to the previous employee state, then it uses that and new data to construct an all-new instance, returning it to the caller. It may seem like a lot of boilerplate code to perform a simple variable update, which may make you long to return to a mutable strategy, but if you're committed to the benefits of immutable data, you will need to get used to some extra code.
Another downside of this approach is that it introduces some difficulty in refactoring. If you were to add, remove, or alter any of the properties of Employee, you could end up with a lot of rework to do.
This approach tends to keep business logic separate from the data, since the update functions are normally written in a completely different part of your code base. For some projects, that can be a big advantage.
State update class methods
If you prefer to keep everything related to state manipulation with the state code, you can use class methods instead of separate, top-level functions:
class Employee {
final int id;
final String name;
const Employee(this.id, this.name);
Employee updateId(int id) {
return Employee(id, name);
}
Employee updateName(String name) {
return Employee(id, name);
}
}
With this approach, you can be less verbose in your naming, since it's clear that each update method belongs to the Employee class. Also, you no longer need to explicitly pass in the old state, because it's assumed that the current instance is the old state. Without good code coloring, it may look as though both update methods have identical code, but updateId()
is creating a new instance of Employee with the incoming id
argument and the old name
. The updateName()
method is doing the opposite.
A downside of doing things this way is that the logic for updating the values is somewhat fixed, tied directly to the state class. This may be exactly what you want in some cases, while in others it may not matter either way.
Keeping it all straight: Separation of concerns is something most professional developers vehemently espouse, but you should consider your needs carefully. Generally speaking, the more you separate your concerns, the more flexible your architecture, but excessive separation can create organizational challenges.
Creating update methods for every property in an immutable class could get cumbersome. Next, we'll look at a way to consolidate that functionality into a single method.
Copy methods
A common pattern used in Dart and Flutter projects with immutable data is adding a copyWith()
method to a class. It can make whatever strategy you are using simpler and more uniform:
class Employee {
final int id;
final String name;
const Employee(this.id, this.name);
Employee copyWith({int? id, String? name}) {
return Employee(
id ?? this.id,
name ?? this.name,
);
}
}
The copyWith()
method should usually use nullable named optional parameters without defaults. The return
statement uses Dart's if null operator, ??
, to determine whether the copy of the employee should get a new value for each property or keep the existing state's value. If the method receives a value for id
, it will not be null
, so that value will be used in the copy. If it's absent or explicitly set to null
, this.id
will be used instead. The copy method is flexible, allowing any number of properties to be updated in a single call.
Example uses of copyWith()
:
final emp1 = Employee(1, "Bob");
final emp2 = emp1.copyWith(id: 3);
final emp3 = emp1.copyWith(name: "Jim");
final emp4 = emp1.copyWith(id: 3, name: "Jim");
When this code executes, the emp2
variable will reference a copy of emp1
with an updated id
value, but name
will be unchanged. The emp3
copy will have a new name and the original ID. With this Employee class, the emp4
copy operation is identical to creating a new object altogether, as it replaces every value.
State update functions or methods can make use of copyWith()
to perform their tasks, which can simplify your code considerably:
Employee updateEmployeeId(Employee oldState, int id) {
return oldState.copyWith(id: id);
}
Employee updateEmployeeName(Employee oldState, String name) {
return oldState.copyWith(name: name);
}
You may even consider the use of state update functions here to be overkill, as they're now such thin wrappers around the call to copyWith()
. In many cases, it's fine to allow external code to directly use the copy function, since there is no way to corrupt the original object's data.
When properties of your immutable classes are also immutable classes, you may need to nest calls to copyWith()
to update nested properties. We discuss that scenario next.
Updating complex properties
What if one or more of your properties is also an immutable object? These update patterns work all the way down the tree:
class EmployeeName {
final String first;
final String last;
const EmployeeName({this.first, this.last});
EmployeeName copyWith({String first, String last}) {
return EmployeeName(
first: first ?? this.first,
last: last ?? this.last,
);
}
}
class Employee {
final int id;
final EmployeeName name;
const Employee(this.id, this.name);
Employee copyWith({int id, EmployeeName name}) {
return Employee(
id: id ?? this.id,
name: name ?? this.name,
);
}
}
Now, Employee contains a property of type EmployeeName, and both classes are immutable and feature a copyWith()
method to facilitate updates. With this setup, if you needed to update an employee's last name, you could do this:
final updatedEmp = oldEmp.copyWith(
name: oldEmp.name.copyWith(last: "Smith"),
);
As you can see, in order to update an employee's last name, it's necessary to use both versions of copyWith()
together.
Updating collections
The pattern you use to update immutable collections depends both on how you're setting up your collections and how much of an immutability purist you are.
To keep our discussion focused on the update patterns, we'll use an unrealistically simplistic data class:
class NumberList {
final List<int> _numbers;
List<int> get numbers => List.unmodifiable(_numbers);
NumberList(this._numbers);
}
This class technically has a mutable list, but only exposes an unmodifiable copy to the outside world. To update this list with a state update function:
NumberList addNumber(NumberList oldState, int number) {
final list = oldState.numbers.toList();
return NumberList(list..add(number));
}
This approach is not extremely efficient. The expression oldState.numbers
delivers us a copy of the oldState
list, but it's unmodifiable, so we need to use toList()
to make yet another copy, this one mutable. Then we create a new NumberList, passing it our copy of the list with a new number added. We use Dart's cascade operator (..
) to perform the add on the list before it gets sent into the constructor.
We could try an update method:
class NumberList {
final List<int> _numbers;
List<int> get numbers => List.unmodifiable(_numbers);
NumberList(this._numbers);
NumberList add(int number) {
return NumberList(_numbers..add(number));
}
}
There are nice things about this method. It's less verbose and requires less code. One subtlety to be aware of is that we're mutating and reusing _numbers
. This is only possible from within internal code, so you may be satisfied with this approach, but there is a potential side effect regarding equality comparisons.
Some state management patterns produce streams of state. Every time a new state is created (every time it gets updated), the new state instance is fed into the stream and delivered to listening UI code. For maximum efficiency, you might check whether a newly received state is actually different from the prior one. Our add()
code above creates a new instance of NumberList, but not a new instance of _numbers
. Depending on how an equality comparison is implemented, comparison code could be fooled into thinking we're continually producing the same state, because the list reference stored in _numbers
never changes.
For this reason and others, some prefer to recreate the list with every change:
class NumberList {
final List<int> _numbers;
List<int> get numbers => List.unmodifiable(_numbers);
NumberList(this._numbers);
NumberList add(int number) {
return NumberList(_numbers.toList()..add(number));
}
}
Adding a call to toList()
fixes the problem, as it creates a copy of _numbers
, adding the new value to that copy, and returning a new instance of NumberList complete with our new, updated list.
Updating elements within a collection
The prior examples work well if you want to add/remove elements, as these operations are available on the default collection classes. For updating immutable collection elements, it can be helpful to define an extension to make it more convenient. For this example, we'll look at doing so with a List:
extension ListX on List {
void replaceAt(int index, replacement) {
this[index] = replacement;
}
void replaceWith(original, replacement) {
replaceAt(indexOf(original), replacement);
}
}
With these handy methods added to the List class, it's possible to simply replace any single element by index or identity. Here's an example of using the extension method replaceWith()
:
final emp1 = Employee("1A", "Jim");
final emp2 = Employee("1B", "Jeff");
final emp3 = Employee("1C", "Tina");
final employees = [emp1, emp2, emp3];
final emp4 = Employee("1D", "Mary");
final newEmployeeList = List.unmodifiable(
employees.toList()..replaceWith(emp2, emp4)
);
Jeff has been fired and he'll be replaced with a new employee, Mary. The newEmployeeList
will end up being a copy of employeeList
with Jeff replaced by Mary.
To replace multiple elements over an index range, you can use List's replaceRange()
method.
Conclusion
There are many ways of handling object and collection immutability, and now you should be familiar with some of the ways the Dart pros go about keeping even complex data from unexpectedly mutating. We didn't even get into the myriad ways immutability can be accomplished through code generation, which can save you some typing, but if you'd like to learn more about that, look at Dart packages like built_value.