You've successfully subscribed to developer.school
Great! Next, complete checkout for full access to developer.school
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.
Success! Your billing info is updated.
Billing info update failed.

How to use Freezed with Flutter

Paul Halliday
Paul Halliday

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:

Immutability and Equality in Dart (and Flutter)
In this article we’re going to investigate ways that we can make robust classes with Dart. We’ll start by creating a basic Product class with a few properties and slowly build it up to where we’re implementing best practices.

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
pubspec.yaml

Here's a rundown of what these dependencies do:

  1. freezed_annotation is used to add the @freezed decorator to tell the build_runner to run the freezed tasks.
  2. build_runner is used by freezed to generate the model files and is able to watch 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);
}
src/models/product.dart

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",
      ),
    );
  }
}
src/pages/freezed_example.dart

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(),
    );
  }
}
main.dart

Here's what it looks like in action:

There's a FAB at the bottom. :)

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:

  1. We've added the @freezed annotation and part 'product.freezed.dart' above our class to tell the freezed library that we want this class to be included in the generator.

    Any time you want a class to be generated by freezed you'll need to include the part 'name.freezed.dart' and the @freezed annotation.
  2. Next, we've made the Product class abstract and included the $Product mixin which freezed has generated. Our Product factory constructor is redirected to _Product which once again comes from the part.

    In your examples, replace Product 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)
Yay! No errors.

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().

Here's our options when we type _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;
}
@Default comes from 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;
}
I appreciate that this example does not age well... :)

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:

Uh oh.

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)
    ];
  }
  
  //
}
We'll be improving on this soon!

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:

Tada!

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!

Flutter

Paul Halliday

👋 Want to see more content? Head over to the YouTube channel: https://youtube.com/c/paulhalliday!