Flutter Freezed – Working with Immutable’s

There are times when immutable objects can make your life a lot simpler. Freezed is here to simplify working with immutable’s in Flutter. As they say: yet another code generator for unions/pattern-matching/copy. They promise simple syntax without compromising the number of features. Before we start let’s take a quick look at what exactly is an immutable object.

An object whose state cannot be changed after construction

https://en.wikipedia.org/wiki/Immutable_object

But why would you want to work with immutable’s? This is a question asked across almost every programming language. Some of the benefits compared to mutable objects:

  1. Immutability makes it easier to reason about the code
  2. Immutable objects are thread-safe
  3. Preventing illegal states by validating the state at the constructor

Setup the Project

Before we can start with coding, we are going to add the Freezed dependency to the project.

dependencies:
  flutter:
    sdk: flutter
  freezed_annotation: ^0.12.0

dev_dependencies:
  build_runner:
  freezed: ^0.12.2

Do not forget to install the dependency, running the following command:

flutter pub get

Creating an Immutable Object

We are going to rewrite the following example. Each food has a name and a price. Displaying the list is done by using a ListView. Therefore we have to map the food objects to a new Widget, in our case a ListTile that displays the name and the price.

class FoodList extends StatelessWidget {
  final List<Food> foods = [Food("Banana", 10.0)];

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Flexible(
          child: ListView.builder(
              itemCount: foods.length,
              itemBuilder: (BuildContext context, int index) {
                var food = foods[index];
                return ListTile(
                  title: Text(food.name),
                  subtitle: Text("€ ${food.price}"),
                );
              }),
        ),
      ],
    );
  }
}

class Food {
  final String name;
  final double price;
  Food(this.name, this.price);
}

Of course, the Food object is the interesting part here. In other words, we are going to make the Food object Immutable. We can do this by adding an annotation @freezed. Furthermore, we have to make the class abstract and add a factory method as a constructor. Finally, we have to add the part statement for the file that will be generated with Freezed. Since the file that contains this object is called food.dart, we will add the following statement: part ‘food.freezed.dart’;

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter/foundation.dart';

part 'food.freezed.dart';

@freezed
abstract class Food with _$Food {
  factory Food({String name, double price}) = _Food;
}

If you are coding along, you will see a lot of errors! Do not worry, this is normal as we have not generated the files yet. To generate the files we are going to run the following command:

flutter pub run build_runner build

Now we are going to update the original list. Here we can create the food object with named parameters. However, we can also create a new Food object without a name and a price.

  final List<Food> foods = [Food(name: "Banana", price: 10.0), Food()];

There are multiple options to prevent that from happening. The first one is to add a @required annotation. Secondly, we can add a default value for the variable with a @Default annotation. Thirdly, we can change the factory method, to make sure the variables are not optional.

// option 1
@freezed
abstract class Food with _$Food {
  factory Food({@required String name, @Default(10) double price}) = _Food;
}
// option 2
@freezed
abstract class Food with _$Food {
  factory Food(String name, double price) = _Food;
}

Don’t forget to rerun the generator after making changes!

Current display of the list

Union objects in Flutter

Freezed provides another cool feature, namely Union objects. With Union objects, we can easily create different types of Food. For example, suppose we have food that is paid per piece and food that is paid per kg. We can easily create two factory methods for both options.

@freezed
abstract class Food with _$Food {
  factory Food.perKg({@required String name, @Default(10) double price}) = FoodPerKg;
  factory Food.perPiece({@required String name, double price, @Default(1) int min}) = FoodPerPiece;
}

To update the list, we are going to create another food object. Now we can map each of the food objects in the list, to a different Widget. The map function detects the different options and you will only have to implement the functions.

class FoodList extends StatelessWidget {
  final List<Food> foods = [
    Food.perKg(name: "Banana", price: 10.0),
    Food.perPiece(name: "Candy", price: 5.0, min: 2)
  ];

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Flexible(
          child: ListView.builder(
              itemCount: foods.length,
              itemBuilder: (BuildContext context, int index) {
                var food = foods[index];
                return food.map(
                    perKg: (Food food) => ListTile(
                        title: Text(food.name),
                        subtitle: Text("€ ${food.price} per kg")),
                    perPiece: (FoodPerPiece food) => ListTile(
                        title: Text(food.name),
                        subtitle: Text(
                            "€ ${food.price} per piece, min ${food.min}")));
              }),
        ),
      ],
    );
  }
}

There are more function to help with handling Union objects:

  • maybeMap, same as map but no need to implement each case and adds an orElse for the fallback option
                return food.maybeMap(
                    perPiece: (FoodPerPiece food) => ListTile(
                        title: Text(food.name),
                        subtitle:
                            Text("€ ${food.price} per piece, min ${food.min}")),
                    // we do have access to both name and price, since both object contain name and price
                    orElse: () => ListTile(
                        title: Text(food.name),
                        subtitle: Text("€ ${food.price}")));
  • when, same as map, except that the values are deconstructed.
                return food.when(
                    perKg: (String name, double price) => ListTile(
                        title: Text(name),
                        subtitle: Text("€ $price")),
                    perPiece: (String name, double price, int min) => ListTile(
                        title: Text(name),
                        subtitle: Text("€ $price per piece, min $min")));
              }),
  • maybeWhen, same as when but no need to implement each case and adds an orElse for the fallback option
                return food.maybeWhen(
                    perPiece: (String name, double price, int min) => ListTile(
                        title: Text(name),
                        subtitle:
                            Text("€ $price per piece, min $min")),
                    // we do have access to both name and price, since both object contain name and price
                    orElse: () => ListTile(
                        title: Text(food.name),
                        subtitle: Text("€ ${food.price}")));
              }),
List with different types!

Copying objects with Freezed

During some of the state management solutions, the state is updated when the state is set to a new value. For this, the copyWith function is a big help. Let’s suppose the food object we created was our state. If we wanted to update our state, then we would have created a new food object with the values of the original food object.

// before
 void updatePrice(Food food, double newPrice) {
    state = Food(food.name, newPrice);
  }// after
 void updatePrice(Food food, double newPrice) {
    state = food.copyWith(price: newPrice);
  }

Currently, we only have two values on our object, but the more complex your objects become the handier the copyWith function becomes. However, the code already becomes much clearer as we are only interested in updating the new price. Without copyWith, you also have to think about the other variables. In the next blog post, we will show you how to combine this package with the Riverpod State Management solution described earlier.

Conclusion

These are just some of the features made available with Freezed. While it makes the build process a bit more complicated by having to generate your code, it does make your life easier in many ways. As always you should consider the trade-off for your use case. If your objects change a lot, there might also be a performance trade-off to be considered.

One comment

Leave a Reply