Flutter Freezed: a short guide on how to use it and reduce boilerplate code

Flutter Freezed: a short guide on how to use it and reduce boilerplate code
Photo by Osman Rana / Unsplash

Boilerplate code represents chunks of code that are minimally varied and replicated elsewhere.

It is never fun to write boilerplate code but do not confuse boilerplate code with repetitive code.

For example, if you want to create an HTML document, you must write html, head, and body tags otherwise your HTML document won’t be valid.

On the other hand, if you write 10 divs that display the same title and text in that HTML document, that is considered repetitive code.

Repetitive code is caused by a developer and (probably) can be reduced, while boilerplate code must be written for things to work.

The problem

In almost every Flutter project, you need to connect to the external API, fetch the data, use it in the state, and display it for the user.

To do so, you need a model, a class that will represent your data.

For example, if you are fetching users from the API and every user has an id, name, and username, you need to create something like this:

@immutable
class User {
  final int id;
  final String name;
  final String username;

  const User({
    required this.id,
    required this.name,
    required this.username,
  });
}

You want to serialize JSON data so you must add toJson and fromJson methods:

factory User.fromJson(Map<String, Object?> json) {
  return User(
      id: json['id'] as int,
      name: json['name'] as String,
      username: json['username'] as String,
  );
}

Map<String, Object?> toJson() {
  return {
      "id": id,
      "name": name,
      "username": username,
  };
}

Since the class will probably be used in the state and you want to take care of immutability, you have to add the copyWith method as well:

User copyWith({
  int? id,
  String? name,
  String? username,
}) {
  return User(
      id: id ?? this.id,
      name: name ?? this.name,
      username: username ?? this.username,
  );
}

There is a high chance you will compare objects for equality, so overriding the == operator must be implemented.

Overriding the hashcode is recommended and comes automatically with it:

@override
bool operator ==(covariant User other) {
  return id == other.id && name == other.name && username == other.username;
}

@override
int get hashCode {
  return Object.hashAll([id, name, username]);
}

Finally, it would be also nice to override the toString method for easier logging:

@override
String toString() {
  return 'User('
    'id $id, '
    'name $name, '
    'username $username, '
    ')';
}

Huh, this is a lot of code to write, but finally here is the result:

import 'package:flutter/material.dart';

@immutable
class User {
  final int id;
  final String name;
  final String username;

  const User({
    required this.id,
    required this.name,
    required this.username,
  });

  factory User.fromJson(Map<String, Object?> json) {
    return User(
      id: json['id'] as int,
      name: json['name'] as String,
      username: json['username'] as String,
    );
  }

  Map<String, Object?> toJson() {
    return {
      "id": id,
      "name": name,
      "username": username,
    };
  }

  User copyWith({
    int? id,
    String? name,
    String? username,
  }) {
    return User(
      id: id ?? this.id,
      name: name ?? this.name,
      username: username ?? this.username,
    );
  }

  @override
  String toString() {
    return 'User('
        'id $id, '
        'name $name, '
        'username $username, '
        ')';
  }

  @override
  bool operator ==(covariant User other) {
    return id == other.id && name == other.name && username == other.username;
  }

  @override
  int get hashCode {
    return Object.hashAll([id, name, username]);
  }
}

That’s a big class.

But what if we can just write our class with minimal code and let freezed generate the rest for us?

The solution

With freezed package, you can get all stuff mentioned above without typing it manually.

But first, we need to do some setup.

In your flutter project, you must add a build runner (the tool to run code generators):

flutter pub add -d build_runner

Then install freezed together with freezed_annotation (at the time of writing this article, the newest version of freezed is 2.3.2 and freezed_annotation is 2.2.0):

flutter pub add -d freezed
flutter pub add freezed_annotation

If you intend to use freezed to generate fromJson and toJson methods, you must also add:

flutter pub add -d json_annotation 
flutter pub add json_serializable

That’s all you need.

Show me the example

In this example, data will be fetched from the jsonplaceholder users API and displayed in ListView.

First, this is how the user is represented in JSON:

{
    "id": 1,
    "name": "Leanne Graham",
    "username": "Bret",
    "email": "Sincere@april.biz",
    "address": {
        "street": "Kulas Light",
        "suite": "Apt. 556",
        "city": "Gwenborough",
        "zipcode": "92998-3874",
        "geo": {
        "lat": "-37.3159",
        "lng": "81.1496"
        }
    },
    "phone": "1-770-736-8031 x56442",
    "website": "hildegard.org",
    "company": {
        "name": "Romaguera-Crona",
        "catchPhrase": "Multi-layered client-server neural-net",
        "bs": "harness real-time e-markets"
    }
}

As you can see, there are some nested objects:

  • geo object is part of an address
  • address is part of the user
  • company is part of the user

So with this in mind, let’s create a models folder, and inside it, the user.dart file. Inside it, the user model can be described like this:

import 'package:freezed_annotation/freezed_annotation.dart';

part 'user.freezed.dart';
part 'user.g.dart';

@freezed
class Geo with _$Geo {
  const factory Geo({
    required String lat,
    required String lng,
  }) = _Geo;

  factory Geo.fromJson(Map<String, Object?> json) => _$GeoFromJson(json);
}

@freezed
class Address with _$Address {
  const factory Address({
    required String street,
    required String suite,
    required String city,
    required String zipcode,
    Geo? geo,
  }) = _Address;

  factory Address.fromJson(Map<String, Object?> json) =>
      _$AddressFromJson(json);
}

@freezed
class Company with _$Company {
  const factory Company({
    required String name,
    required String catchPhrase,
    required String bs,
  }) = _Company;

  factory Company.fromJson(Map<String, Object?> json) =>
      _$CompanyFromJson(json);
}

@freezed
class User with _$User {
  const factory User({
    required int id,
    required String name,
    required String username,
    required String email,
    required String phone,
    required String website,
    Company? company,
    Address? address,
  }) = _User;

  factory User.fromJson(Map<String, Object?> json) => _$UserFromJson(json);
}

You will probably get some errors in the file as user.freezed.dart and user.g.dart files do not exist (yet).

You can silence these errors if you add the following lines to your analysis_options.yaml file:

analyzer:
  errors:
    invalid_annotation_target: ignore

Now to generate the code hit command:

flutter pub run build_runner build

Bam! You will get 2 new files in the models folder:

  • user.g.dart - for toJson and fromJson methods
  • user.freezed.dart - for everything else (toString, copyWith, equality, etc.)

Now to put it to the test, here is the minimal code:

import 'dart:async';

import 'package:dio/dio.dart';
import 'package:flutter/material.dart';

import './models/user.dart';

void main() {
  runApp(const MyApp());
}

Future<List<User>> fetchUsersData() async {
  final dio = Dio();
  final response = await dio.get('https://jsonplaceholder.typicode.com/users');
  if (response.statusCode == 200) {
    Iterable userList = response.data;
    return List<User>.from(userList.map((userJson) => User.fromJson(userJson)));
  } else {
    throw Exception("An error occurred on fetching users data!");
  }
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Freezed guide',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Users'),
      ),
      body: FutureBuilder<List<User>>(
        future: fetchUsersData(),
        builder: (context, snapshot) {
          if (snapshot.hasData) {
            return ListView.builder(
              itemCount: snapshot.data!.length,
              itemBuilder: (BuildContext context, int index) {
                final user = snapshot.data![index];
                return ListTile(
                  title: Text(user.name),
                  subtitle: Text(
                      '${user.address!.street}, ${user.address!.city}, ${user.address!.zipcode}'),
                  tileColor: Colors.white,
                );
              },
            );
          } else if (snapshot.hasError) {
            return Text(snapshot.error.toString());
          }
          return const Center(
            child: CircularProgressIndicator(),
          );
        },
      ),
    );
  }
}

As you can see, users are fetched from the API, serialized, and displayed in ListView:

You can find the full code on GitHub here.

Enjoy!