
How to use Freezed with Flutter
If you're new to Flutter or haven't used immutable classes before, you may not see an immediate value in using freezed
. This article is here to show you why you should consider thinking about this topic and then how to implement this with freezed
.
If you haven't read Part 1 of this series where we investigate immutability and equality without Freezed, check it out here:

We'll be using the same Product
example to keep things consistent.
With that in mind, let's dive right in by creating a new Flutter project and adding freezed
!
Project Setup
Run the following in your terminal to follow along, or just add the pubspec.yaml
items to your own project:
$ flutter create ds_freezed
$ cd my_freezed
$ code . (or open in your favourite editor)
Before opening this inside of your simulator or device, add the following to your pubspec.yaml
:
dependencies:
flutter:
sdk: flutter
freezed_annotation: ^0.12.0
dev_dependencies:
flutter_test:
sdk: flutter
build_runner:
freezed: ^0.12.2
Here's a rundown of what these dependencies do:
freezed_annotation
is used to add the@freezed
decorator to tell thebuild_runner
to run thefreezed
tasks.build_runner
is used byfreezed
to generate the model files and is able towatch
for any changes and rebuild.
If you've ever used a library such as json_serializable
to parse toJson/fromJson
within your models, you'll have likely seen/interacted with build_runner
before.
Product Model
Next up, go ahead and create the Product
model at src/models/product.dart
which is what we'll be using throughout:
import 'package:flutter/material.dart';
@immutable
class Product {
final String _id;
String get id => _id;
final String _name;
String get name => _name;
final Color _color;
Color get color => _color;
const Product({
@required String id,
@required String name,
Color color = Colors.red,
}) : _id = id,
_name = name,
_color = color,
assert(id != null),
assert(name != null);
Product copyWith({
String id,
String name,
Color color,
}) =>
Product(id: id ?? _id, name: name ?? _name, color: color ?? _color);
@override
bool operator ==(Object other) =>
other is Product &&
other._id == _id &&
other._name == _name &&
other._color == _color;
@override
int get hashCode => hashValues(_id, _name, _color);
}
Page
After that, we can create a FreezedExamplePage
at src/pages/freezed_example.dart
which simply shows information about a Product
on screen:
class FreezedExamplePage extends StatefulWidget {
@override
_FreezedExamplePageState createState() => _FreezedExamplePageState();
}
class _FreezedExamplePageState extends State<FreezedExamplePage> {
Random _random;
Product _product;
@override
void initState() {
super.initState();
_product = Product(id: "1", name: "iPhone 12");
_random = Random();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Immutability and Equality"),
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.refresh),
onPressed: _randomProductData,
),
body: ListTile(
leading: CircleAvatar(
backgroundColor: _product.color,
),
title: Text(
_product.name,
),
subtitle: Text(
_product.id,
),
),
);
}
void _randomProductData() {
final randomNumber = _random.nextInt(12);
setState(
() => _product = _product.copyWith(
name: "iPhone $randomNumber",
),
);
}
}
Update your main.dart
with this new page as the home
:
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
debugShowCheckedModeBanner: false,
home: FreezedExamplePage(),
);
}
}
Here's what it looks like in action:

You'll notice that if you select the Floating Action Button, it'll generate a new Product
with the name of iPhone $randomNumber
.
Using Freezed
Head over to src/models/product.dart
and replace it with the following:
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'product.freezed.dart';
@freezed
abstract class Product with _$Product {
const factory Product({
String id,
String name,
Color color,
}) = _Product;
}
Here's a rundown of what's happening here:
- We've added the
@freezed
annotation andpart 'product.freezed.dart'
above our class to tell thefreezed
library that we want this class to be included in the generator.
Any time you want a class to be generated byfreezed
you'll need to include thepart 'name.freezed.dart'
and the@freezed
annotation. - Next, we've made the Product class
abstract
and included the$Product
mixin whichfreezed
has generated. OurProduct
factory constructor is redirected to_Product
which once again comes from thepart
.
In your examples, replaceProduct
with the intended name of your class and leave everything the same.
Starting build_runner
Inside of your terminal within the project, run:
$ flutter pub run build_runner watch
This will start watching for any further changes as we continue to develop. If you wanted to run this once, you'd type:
$ flutter pub run build_runner build
If you ever submit your built files to source control, you'll need to ensure that these files are deleted prior to running build_runner
. Don't worry though, you don't have to do this manually:
$ flutter pub run build_runner watch --delete-conflicting-outputs
After running the watch
, it should pick up the Product
class marked with @freezed
and generate a product.freezed.dart
:
INFO] Running build...
[INFO] 1.0s elapsed, 0/4 actions completed.
[INFO] 2.1s elapsed, 0/4 actions completed.
[INFO] 11.1s elapsed, 0/4 actions completed.
[INFO] 12.6s elapsed, 3/4 actions completed.
[INFO] Running build completed, took 12.6s
[INFO] Succeeded after 475ms with 1 outputs (4 actions)
Now, trigger a restart on your simulator/device. You'll notice everything works like before:

Immutable by Default
The first thing that you'll notice is that we get immutability by default. Sure, it generated the copyWith
for us, but we're also unable to directly assign the properties as expected:

As you can see from the error message, whilst there is a getter for the name
, a setter
has not been created so value assignment isn't possible. You may remember this from the Removing Setters part of the first part in this series.
The best thing about it? We didn't have to create public facing get
properties ourself!
Here's the correct way to assign new values now:
_product = Product(id: "1", name: "iPhone 12");
final _newProduct = _product.copyWith(
name: "My new name",
);
If we tap the Floating Action Button, it's still able to retain our immutability by assigning a new Product
using _product.copyWith()
.

_product.
Default Values
Uh oh. It looks like we're not exactly a 1:1 to our previous example, because the Color
of our Product
will be null. Thankfully, CircularAvatar
makes this blue by default.
We can add a default value to our color
like so:
@freezed
abstract class Product with _$Product {
const factory Product({
String id,
String name,
@Default(Colors.red) Color color,
}) = _Product;
}
freezed_annotation
.Required Values
We can add required values in the same way as we did previously, with the @required
annotation:
@freezed
abstract class Product with _$Product {
const factory Product({
@required String id,
@required String name,
@Default(Colors.red) Color color,
}) = _Product;
}
You'll notice that if we forget to add a name
or id
, we get a warning about it from the dart analyzer.

Usually, this wouldn't stop execution (for example, in our previous article), but freezed
has smart default behaviour here.
If the property is marked with @Default
or @required
and doesn't compile (because it is null
) then it will not execute because it already adds an assert(id != null && name != null)
. This gives us extra safety for free!
Assertions
On the topic of assertions, just because freezed
adds in an assert(x != null)
for a @required
or @Default
member, it doesn't mean that we don't want to add our own assert
statements.
Let's be a little silly and make it so that the name
cannot be equal to iPhone 13
with the debug message of, iPhone 13 has yet to be released!
@freezed
abstract class Product with _$Product {
@Assert("name != 'iPhone 13', 'iPhone 13 has yet to be released!'")
const factory Product({
@required String id,
@required String name,
@Default(Colors.red) Color color,
}) = _Product;
}
Notice how we add the @Assert
annotation above the Product
factory and that the evaulated assertion has to be a String
.
Head over to our FreezedExamplePage
and switch up the _product
to have the name of iPhone 13
:
@override
void initState() {
// Rest of initState
_product = Product(id: "1", name: "iPhone 13");
}
If we refresh our application, we get the following error message:

Let's change it back to iPhone 12 for now:
@override
void initState() {
// Rest of initState
_product = Product(id: "1", name: "iPhone 12");
}
Comments
In the same way that we can add annotations to our fields, we can also add documentation comments like so:
@freezed
abstract class Product with _$Product {
const factory Product({
@required String id,
/// This is the name of the product.
///
/// It's required must not be null.
@required String name,
@Default(Colors.red) Color color,
}) = _Product;
}
If we hover over the product.name
with our mouse cursor, our development environment should show us this tooltip:

Deprecation
Marking fields as deprecated still works with freezed
by simply adding the @deprecated
annotation:
const factory Product({
@required String id,
@required otherName,
@Default(Colors.red) Color color,
@deprecated String name,
}) = _Product;
If you want to provide an alternative (or other text) to the user, use:
const factory Product({
@required String id,
@required otherName,
@Default(Colors.red) Color color,
@Deprecated('You should use otherName instead') String name,
}) = _Product;
This gives a user a prompt inside of the editor/analyzer errors:

It should be said that @deprecated is Dart functionality and not freezed
, I'm merely bringing it to your attention here. :)
Let's revert the @Deprecated
annotation on name
and remove otherName
.
toString()
Just like in our prior article, we want to have the ability to print a String
version of our Product
with the current instance values. Let's give it a try by updating our initState
with a call to print(_product)
:
_product = Product(id: "1", name: "iPhone 12");
print(_product);
This gives us the following result:
Product(
id: 1,
name: iPhone 12,
color: MaterialColor(primary value: Color(0xfff44336))
)
Perfect! Right? It gives us a reasonable toString
that matches the majority of use-cases.
Let's take a look at how to override toString
:
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'product.freezed.dart';
@freezed
abstract class Product with _$Product {
const Product._();
@Assert("name != 'iPhone 13', 'iPhone 13 has yet to be released!'")
const factory Product({
@required String id,
@required String name,
@Default(Colors.red) Color color,
}) = _Product;
@override
String toString() {
return "Product ID = $id, Name = $name";
}
}
Simply using the standard @override
annotation won't work here as it returns us:
Instance of '_$_Product'
In order to appropriately override the toString
method, we need to declare an named private constructor with the use of:
@freezed
abstract class Product with _$Product {
const Product._();
//
}
With our new toString
method implemented, we get the following inside our console on print
:
Product ID = 1, Name = iPhone 12
Unions
We've already seen how much freezed
gives us for free, but we haven't scratched the surface of it's true potential. Imagine if we could differentiate between a Phone
and Insurance
easily inside of our code-base by using a when(phone: X, insurance: Y)
method.
We're doing exactly that now - let's update our Product
class to include the following constructors:
@freezed
abstract class Product with _$Product {
const Product._();
@Assert("name != 'iPhone 13', 'iPhone 13 has yet to be released!'")
const factory Product.phone({
@required String id,
@required String name,
@Default(Colors.red) Color color,
}) = _Phone;
const factory Product.insurance({
@required String id,
@required String name,
@required double quote,
}) = _Insurance;
@override
String toString() {
return "Product ID = $id, Name = $name";
}
}
We've now established two factory constructors, both of which point to either Phone
and _Insurance
. This way of defining these constructors means we have to instantiate it like so:
class _FreezedExamplePageState extends State<FreezedExamplePage> {
List<Product> _productList;
@override
void initState() {
super.initState();
_productList = [
Product.phone(id: "1", name: "iPhone 12"),
Product.insurance(id: "2", name: "Home Insurance", quote: 25.44)
];
}
//
}
Because _productList
is a List<Product>
, we can call .map
on an individual Product
to determine whether it is a Product.phone
or Product.insurance
. Here's an example:
class _FreezedExamplePageState extends State<FreezedExamplePage> {
List<Product> _productList;
@override
void initState() {
super.initState();
_productList = [
Product.phone(id: "1", name: "iPhone 12"),
Product.insurance(id: "2", name: "Home Insurance", quote: 25.44)
];
_productList.forEach((Product product) {
product.map(
phone: (Product phone) => print("Phone!"),
insurance: (Product insurance) =>
print("Insurance!"));
});
}
//
}
If we run our code, we get the following inside of the terminal:
flutter: Phone!
flutter: Insurance!
We can take this one step further if we move this to the build
method:
class _FreezedExamplePageState extends State<FreezedExamplePage> {
List<Product> _productList;
@override
void initState() {
super.initState();
_productList = [
Product.phone(id: "1", name: "iPhone 12"),
Product.insurance(id: "2", name: "Home Insurance", quote: 25.44)
];
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Immutability and Equality"),
),
body: ListView.builder(
itemCount: _productList.length,
itemBuilder: (BuildContext context, int index) =>
_productList[index].map(
phone: (Product phone) => _buildPhone(phone),
insurance: (Product insurance) => _buildInsurance(
insurance
),
),
),
);
}
Widget _buildInsurance(Product insurance) {
return ListTile(
leading: Icon(Icons.home),
title: Text(
insurance.name,
),
subtitle: Text(
insurance.id,
),
);
}
Widget _buildPhone(Product phone) {
return ListTile(
leading: CircleAvatar(),
title: Text(
phone.name,
),
subtitle: Text(
phone.id,
),
);
}
}
Our application is now able to switch between showing a ListTile
with information specific to a Phone
and a ListTile
specific to Insurance
.

I can already hear you thinking - "Wouldn't this be better if we could create this as aPhone
, instead of a Product.phone
"?
Yes. We can!
Let's update the Product
model and set the redirected constructor to a public Phone
and Insurance
, unlike the private options as before:
const factory Product.phone({
@required String id,
@required String name,
@Default(Colors.red) Color color,
}) = Phone;
const factory Product.insurance({
@required String id,
@required String name,
@required double quote,
}) = Insurance;
We can then update our page to use the Phone
and Insurance
classes:
class _FreezedExamplePageState extends State<FreezedExamplePage> {
List<Product> _productList;
@override
void initState() {
super.initState();
_productList = [
Phone(id: "1", name: "iPhone 12"),
Insurance(id: "2", name: "Home Insurance", quote: 25.44)
];
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Immutability and Equality"),
),
body: ListView.builder(
itemCount: _productList.length,
itemBuilder: (BuildContext context, int index) =>
_productList[index].map(
phone: (Phone phone) => _buildPhone(phone),
insurance: (Insurance insurance) => _buildInsurance(insurance),
),
),
);
}
Widget _buildInsurance(Insurance insurance) {
return ListTile(
leading: Icon(Icons.home),
title: Text(
insurance.name,
),
subtitle: Text(
insurance.id,
),
);
}
Widget _buildPhone(Phone phone) {
return ListTile(
leading: CircleAvatar(),
title: Text(
phone.name,
),
subtitle: Text(
phone.id,
),
);
}
}
Building UI Based on UIState
Continuing in this same vein, I'd like to build on the above to determine the state of the UI based on whether it is Initial
, Loading
, Loaded
, or Error
. We can build the UIState
class like so:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'ui_state.freezed.dart';
@freezed
abstract class UIState with _$UIState {
const factory UIState.initial() = Initial;
const factory UIState.loading() = Loading;
const factory UIState.success() = Success;
const factory UIState.error(Failure failure) = Error;
}
This also requires a Failure
for our Error
state:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'failure.freezed.dart';
@freezed
abstract class Failure with _$Failure {
const factory Failure(String message) = _Failure;
const factory Failure.network(
[@Default("You've got no internet connection.") String message]) =
NetworkFailure;
}
We have two options here - either use a Failure
or NetworkFailure
, and I'm sure we can create multiple other sensible defaults for our example.
Let's create a UserService
which either returns us a List<Product>
or throws a random Failure
:
import 'dart:math';
import 'package:ds_freezed/src/models/failure.dart';
import 'package:ds_freezed/src/models/product.dart';
Random _random = Random();
class ProductService {
Future<List<Product>> getProducts() {
try {
return Future.delayed(Duration(seconds: 2), () {
_randomlyThrowError();
return [
Phone(id: "1", name: "iPhone 12"),
Insurance(id: "2", name: "Home Insurance", quote: 25.44)
];
});
} catch (e) {
throw e;
}
}
void _randomlyThrowError() {
List<Failure> possibleErrors = [
Failure("There was an issue getting products."),
NetworkFailure()
];
final shouldThrowError = _random.nextBool();
if (shouldThrowError) {
throw possibleErrors[_random.nextInt(possibleErrors.length)];
}
}
}
We can then update our FreezedExamplePage
to build our UI based off the current state of UIState
. Here's an example of what our build
method is going to look like:
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Immutability and Equality"),
),
body: _uiState.when(
initial: _buildProductsLoading,
loading: _buildProductsLoading,
success: _buildProductsSuccess,
error: _buildProductsError,
),
);
}
Now that we have the ability to control when certain UI elements should be shown. All we need to do is use setState
and remember to update our _uiState
at the correct time.
I'd recommend moving the UIState into a ChangeNotifier or StateNotifier with Provider or Riverpod. This allows us to manage the state of the UI either app wide or component wide without having to do it inside of our Widget.
Here's what it looks like:
class _FreezedExamplePageState extends State<FreezedExamplePage> {
UIState _uiState;
List<Product> _productList;
ProductService _productService;
@override
void initState() {
super.initState();
_uiState = Initial();
_productService = ProductService();
_getProducts();
}
void _getProducts() async {
try {
setState(() {
_uiState = Loading();
});
final products = await _productService.getProducts();
setState(() {
_productList = products;
_uiState = Success();
});
} on Failure catch (failure) {
setState(() {
_uiState = Error(failure);
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Immutability and Equality"),
),
body: _uiState.when(
initial: _buildProductsLoading,
loading: _buildProductsLoading,
success: _buildProductsSuccess,
error: _buildProductsError,
),
);
}
Widget _buildProductsLoading() {
return Center(
child: CircularProgressIndicator(),
);
}
Widget _buildProductsError(Failure failure) {
return Center(
child: Text(failure.message),
);
}
Widget _buildProductsSuccess() {
return ListView.builder(
itemCount: _productList.length,
itemBuilder: (BuildContext context, int index) => _productList[index].map(
phone: (Phone phone) => _buildPhone(phone),
insurance: (Insurance insurance) => _buildInsurance(insurance),
),
);
}
Widget _buildInsurance(Insurance insurance) {
return ListTile(
leading: Icon(Icons.home),
title: Text(
insurance.name,
),
subtitle: Text(
insurance.id,
),
);
}
Widget _buildPhone(Phone phone) {
return ListTile(
leading: CircleAvatar(),
title: Text(
phone.name,
),
subtitle: Text(
phone.id,
),
);
}
}
The key function that manages the state inside our FreezedExamplePage
is _getProducts()
and you can see the lifecycle and potential states available:
void _getProducts() async {
try {
setState(() {
_uiState = Loading();
});
final products = await _productService.getProducts();
setState(() {
_productList = products;
_uiState = Success();
});
} on Failure catch (failure) {
setState(() {
_uiState = Error(failure);
});
}
}
We're also using when
instead of map
on _uiState
inside of the body
:
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Immutability and Equality"),
),
body: _uiState.when(
initial: _buildProductsLoading,
loading: _buildProductsLoading,
success: _buildProductsSuccess,
error: _buildProductsError,
),
);
}
This is because we're not really concerned with the results of each outcome, the majority of the time we're simply using UIState
as a flag to trigger a method. As we have the same callback for initial
and loading
, we could've also used maybeWhen
:
body: _uiState.maybeWhen(
loading: _buildProductsLoading,
success: _buildProductsSuccess,
error: _buildProductsError,
orElse: _buildProductsLoading
),
We've provided an orElse
here which acts as a "catch all" for anything that hasn't been defined inside of the maybeWhen
. I've elected to stick with the first version as most usages of this will provide a value for all four events.
Summary
In the second part of our Immutability with Dart series we investigated freezed as a way to reduce the boiler plate we have to write. After creating some basic classes we moved on to Unions with UIState
and used it to build UI based on the current UIState
.
I recorded a video for how this looks:
There are a variety of other powerful features that come from the freezed library and I'd recommend you check out the documentation for further review: https://pub.dev/packages/freezed
I hope you've found it helpful!