DartFlutter

Using Freezed in Flutter: Data Classes, Unions, and Code Simplified

Flutter Freezed

Introduction

Writing clean, immutable data classes in Dart can get repetitive fast. Every model needs == operator overrides, hashCode implementations, copyWith methods, and toString for debugging. Multiply this across dozens of models, and you’re looking at thousands of lines of boilerplate that’s error-prone and tedious to maintain.

That’s where Freezed comes in—a powerful code generator that simplifies model creation, enables union/sealed classes, and eliminates boilerplate entirely. Used by companies like Very Good Ventures, BMW, and thousands of Flutter developers worldwide, Freezed has become an essential tool for building production-grade Flutter applications.

In this comprehensive guide, you’ll learn how to use Freezed to build better data models, implement sealed classes for state management, handle JSON serialization, and integrate with popular state management solutions like Riverpod and BLoC.

What Is Freezed?

Freezed is a Dart code generation package that creates immutable classes with value equality, copy methods, pattern matching support, and sealed class capabilities—all from a simple annotation-based syntax.

It’s often used with state management tools like Riverpod, BLoC, and Cubit to manage model and state classes efficiently. Freezed generates all the tedious code for you, letting you focus on business logic rather than boilerplate.

Why Use Freezed?

  • Eliminates boilerplate: No more writing ==, hashCode, copyWith, or toString manually
  • Enables union types: Create sealed classes for exhaustive pattern matching
  • Immutability by default: All generated classes are deeply immutable
  • Null safety: Full integration with Dart’s sound null safety
  • JSON serialization: Seamless integration with json_serializable
  • IDE support: Excellent autocomplete and refactoring support
  • Testing friendly: Value equality makes assertions straightforward

Step 1: Install Freezed and Dependencies

Add the following to your pubspec.yaml:

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  freezed_annotation: ^2.4.1
  json_annotation: ^4.8.1  # For JSON serialization

dev_dependencies:
  build_runner: ^2.4.6
  freezed: ^2.4.5
  json_serializable: ^6.7.1  # For JSON serialization

Then run:

flutter pub get

For faster code generation during development, add this to your build.yaml:

# build.yaml
targets:
  $default:
    builders:
      freezed:
        options:
          # Generate in a single file for faster builds
          format: true
          # Enable copyWith with nullable parameters
          copy_with: true
          # Enable == and hashCode
          equal: true
          # Enable toString
          to_string: true

Step 2: Create a Basic Data Class with Freezed

Create a file called user_model.dart:

// lib/models/user_model.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'user_model.freezed.dart';
part 'user_model.g.dart';  // For JSON serialization

@freezed
class UserModel with _$UserModel {
  const factory UserModel({
    required String id,
    required String name,
    required String email,
    int? age,
    @Default(false) bool isVerified,
    @Default([]) List roles,
    DateTime? lastLoginAt,
  }) = _UserModel;

  // Add custom methods by defining a private constructor
  const UserModel._();

  // Custom getters
  bool get isAdmin => roles.contains('admin');
  
  String get displayName => name.isNotEmpty ? name : email.split('@').first;

  // JSON serialization
  factory UserModel.fromJson(Map json) => _$UserModelFromJson(json);
}

Then run the generator:

# One-time build
flutter pub run build_runner build --delete-conflicting-outputs

# Watch mode for development
flutter pub run build_runner watch --delete-conflicting-outputs

Now you have an immutable class with:

  • Deep value equality (== compares all fields)
  • copyWith for creating modified copies
  • Readable toString() for debugging
  • Proper hashCode for use in Sets and Maps
  • JSON serialization/deserialization

Step 3: Master copyWith for Immutable Updates

The copyWith method is essential for immutable state updates:

// Basic usage
final user = UserModel(
  id: '1',
  name: 'John Doe',
  email: 'john@example.com',
);

// Create a new instance with updated values
final updatedUser = user.copyWith(name: 'Jane Doe');

print(user.name);        // John Doe (original unchanged)
print(updatedUser.name); // Jane Doe (new instance)

// Update multiple fields
final verifiedUser = user.copyWith(
  isVerified: true,
  lastLoginAt: DateTime.now(),
);

// copyWith with nullable fields - set to null explicitly
final clearedUser = user.copyWith.call(
  age: null,  // Explicitly set to null
  lastLoginAt: null,
);

Deep copyWith for Nested Objects

// lib/models/order_model.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'order_model.freezed.dart';
part 'order_model.g.dart';

@freezed
class Address with _$Address {
  const factory Address({
    required String street,
    required String city,
    required String country,
    String? zipCode,
  }) = _Address;

  factory Address.fromJson(Map json) => _$AddressFromJson(json);
}

@freezed
class Order with _$Order {
  const factory Order({
    required String id,
    required UserModel customer,
    required Address shippingAddress,
    required List items,
    required OrderStatus status,
    required DateTime createdAt,
  }) = _Order;

  const Order._();

  double get total => items.fold(0, (sum, item) => sum + item.total);

  factory Order.fromJson(Map json) => _$OrderFromJson(json);
}

@freezed
class OrderItem with _$OrderItem {
  const factory OrderItem({
    required String productId,
    required String name,
    required double price,
    required int quantity,
  }) = _OrderItem;

  const OrderItem._();

  double get total => price * quantity;

  factory OrderItem.fromJson(Map json) => _$OrderItemFromJson(json);
}

enum OrderStatus { pending, processing, shipped, delivered, cancelled }

// Usage with deep copyWith
void updateOrderExample() {
  final order = Order(
    id: 'order-123',
    customer: UserModel(id: '1', name: 'John', email: 'john@example.com'),
    shippingAddress: Address(
      street: '123 Main St',
      city: 'New York',
      country: 'USA',
    ),
    items: [
      OrderItem(productId: 'p1', name: 'Widget', price: 29.99, quantity: 2),
    ],
    status: OrderStatus.pending,
    createdAt: DateTime.now(),
  );

  // Deep copy to update nested address
  final updatedOrder = order.copyWith(
    shippingAddress: order.shippingAddress.copyWith(
      city: 'Los Angeles',
    ),
  );

  // Update customer's verified status
  final verifiedOrder = order.copyWith(
    customer: order.customer.copyWith(isVerified: true),
  );
}

Step 4: Create Union Types (Sealed Classes)

Freezed excels at creating sealed classes (union types) for representing mutually exclusive states. This is perfect for state management:

// lib/state/auth_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'auth_state.freezed.dart';

@freezed
sealed class AuthState with _$AuthState {
  const factory AuthState.initial() = AuthInitial;
  const factory AuthState.loading() = AuthLoading;
  const factory AuthState.authenticated({
    required UserModel user,
    required String accessToken,
    DateTime? tokenExpiresAt,
  }) = Authenticated;
  const factory AuthState.unauthenticated({
    String? message,
  }) = Unauthenticated;
  const factory AuthState.error({
    required String message,
    Exception? exception,
  }) = AuthError;
}

Pattern Matching with when and map

// Exhaustive pattern matching with when
Widget buildAuthUI(AuthState state) {
  return state.when(
    initial: () => const SplashScreen(),
    loading: () => const LoadingSpinner(),
    authenticated: (user, token, expiresAt) => HomeScreen(user: user),
    unauthenticated: (message) => LoginScreen(errorMessage: message),
    error: (message, exception) => ErrorScreen(
      message: message,
      onRetry: () => authController.retry(),
    ),
  );
}

// Use maybeWhen for partial matching
String getStatusMessage(AuthState state) {
  return state.maybeWhen(
    authenticated: (user, _, __) => 'Welcome back, ${user.name}!',
    error: (message, _) => 'Error: $message',
    orElse: () => 'Please wait...',
  );
}

// Use map when you need access to the full variant object
void handleState(AuthState state) {
  state.map(
    initial: (initial) => print('Initial state'),
    loading: (loading) => showLoadingIndicator(),
    authenticated: (auth) {
      saveToken(auth.accessToken);
      navigateToHome(auth.user);
    },
    unauthenticated: (unauth) => navigateToLogin(),
    error: (err) {
      logError(err.exception);
      showErrorDialog(err.message);
    },
  );
}

// Use whenOrNull for optional handling
void maybeShowWelcome(AuthState state) {
  final user = state.whenOrNull(
    authenticated: (user, _, __) => user,
  );
  
  if (user != null) {
    showWelcomeToast('Hello, ${user.name}!');
  }
}

Real-World Pattern: AsyncValue with Freezed

Create a reusable async state wrapper for any data type:

// lib/state/async_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'async_state.freezed.dart';

@freezed
sealed class AsyncState with _$AsyncState {
  const factory AsyncState.initial() = AsyncInitial;
  const factory AsyncState.loading() = AsyncLoading;
  const factory AsyncState.data(T value) = AsyncData;
  const factory AsyncState.error({
    required String message,
    Object? error,
    StackTrace? stackTrace,
  }) = AsyncError;

  const AsyncState._();

  bool get isLoading => this is AsyncLoading;
  bool get hasData => this is AsyncData;
  bool get hasError => this is AsyncError;

  T? get valueOrNull => whenOrNull(data: (value) => value);
}

// Usage example
class ProductsNotifier extends StateNotifier>> {
  final ProductRepository _repository;

  ProductsNotifier(this._repository) : super(const AsyncState.initial());

  Future loadProducts() async {
    state = const AsyncState.loading();
    
    try {
      final products = await _repository.getProducts();
      state = AsyncState.data(products);
    } catch (e, st) {
      state = AsyncState.error(
        message: 'Failed to load products',
        error: e,
        stackTrace: st,
      );
    }
  }
}

// In your widget
Widget build(BuildContext context) {
  final productsState = ref.watch(productsProvider);
  
  return productsState.when(
    initial: () => const Text('Pull to refresh'),
    loading: () => const CircularProgressIndicator(),
    data: (products) => ProductList(products: products),
    error: (message, _, __) => ErrorWidget(message: message),
  );
}

JSON Serialization with Freezed

Freezed integrates seamlessly with json_serializable for API communication:

// lib/models/api_response.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'api_response.freezed.dart';
part 'api_response.g.dart';

@freezed
class ApiResponse with _$ApiResponse {
  const factory ApiResponse({
    required bool success,
    T? data,
    String? message,
    @JsonKey(name: 'error_code') String? errorCode,
    @Default({}) Map metadata,
  }) = _ApiResponse;

  factory ApiResponse.fromJson(
    Map json,
    T Function(Object? json) fromJsonT,
  ) => _$ApiResponseFromJson(json, fromJsonT);
}

// Model with custom JSON keys and converters
@freezed
class Product with _$Product {
  const factory Product({
    required String id,
    required String name,
    @JsonKey(name: 'unit_price') required double price,
    @JsonKey(name: 'stock_quantity') required int stockQuantity,
    @JsonKey(name: 'created_at') required DateTime createdAt,
    @JsonKey(name: 'is_available', defaultValue: true) required bool isAvailable,
    @JsonKey(includeIfNull: false) String? description,
    @Default([]) List tags,
    @JsonKey(name: 'category_id') String? categoryId,
  }) = _Product;

  const Product._();

  bool get inStock => stockQuantity > 0;

  factory Product.fromJson(Map json) => _$ProductFromJson(json);
}

// Custom JSON converter for enums
class OrderStatusConverter implements JsonConverter {
  const OrderStatusConverter();

  @override
  OrderStatus fromJson(String json) {
    return OrderStatus.values.firstWhere(
      (e) => e.name == json,
      orElse: () => OrderStatus.pending,
    );
  }

  @override
  String toJson(OrderStatus object) => object.name;
}

@freezed
class OrderResponse with _$OrderResponse {
  const factory OrderResponse({
    required String id,
    @OrderStatusConverter() required OrderStatus status,
    required double total,
  }) = _OrderResponse;

  factory OrderResponse.fromJson(Map json) => 
      _$OrderResponseFromJson(json);
}

Integration with Riverpod

Freezed and Riverpod are a powerful combination for state management:

// lib/providers/cart_provider.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'cart_provider.freezed.dart';
part 'cart_provider.g.dart';

@freezed
class CartState with _$CartState {
  const factory CartState({
    @Default([]) List items,
    @Default(false) bool isCheckingOut,
    String? couponCode,
    double? discount,
  }) = _CartState;

  const CartState._();

  double get subtotal => items.fold(0, (sum, item) => sum + item.total);
  double get total => subtotal - (discount ?? 0);
  int get itemCount => items.fold(0, (sum, item) => sum + item.quantity);
  bool get isEmpty => items.isEmpty;
}

@freezed
class CartItem with _$CartItem {
  const factory CartItem({
    required Product product,
    required int quantity,
  }) = _CartItem;

  const CartItem._();

  double get total => product.price * quantity;
}

@riverpod
class Cart extends _$Cart {
  @override
  CartState build() => const CartState();

  void addItem(Product product, {int quantity = 1}) {
    final existingIndex = state.items.indexWhere(
      (item) => item.product.id == product.id,
    );

    if (existingIndex >= 0) {
      // Update existing item
      final updatedItems = [...state.items];
      final existing = updatedItems[existingIndex];
      updatedItems[existingIndex] = existing.copyWith(
        quantity: existing.quantity + quantity,
      );
      state = state.copyWith(items: updatedItems);
    } else {
      // Add new item
      state = state.copyWith(
        items: [...state.items, CartItem(product: product, quantity: quantity)],
      );
    }
  }

  void removeItem(String productId) {
    state = state.copyWith(
      items: state.items.where((item) => item.product.id != productId).toList(),
    );
  }

  void updateQuantity(String productId, int quantity) {
    if (quantity <= 0) {
      removeItem(productId);
      return;
    }

    state = state.copyWith(
      items: state.items.map((item) {
        if (item.product.id == productId) {
          return item.copyWith(quantity: quantity);
        }
        return item;
      }).toList(),
    );
  }

  Future applyCoupon(String code) async {
    // Validate coupon with API
    final discount = await ref.read(couponServiceProvider).validate(code);
    state = state.copyWith(
      couponCode: code,
      discount: discount,
    );
  }

  void clearCart() {
    state = const CartState();
  }
}

Common Mistakes to Avoid

Forgetting the part directives: Always include both part 'filename.freezed.dart'; and part 'filename.g.dart'; (for JSON) at the top of your file.

Missing the private constructor for custom methods: If you want to add custom getters or methods, you must include const ClassName._(); as a private constructor.

Not running build_runner after changes: Freezed generates code, so you must run build_runner after any model changes. Use watch mode during development.

Overusing Freezed for simple classes: Not every class needs Freezed. Simple value objects with 1-2 fields might not benefit from the added complexity.

Ignoring the generated file size: Freezed generates substantial code. For large projects, this can impact build times. Consider splitting models across multiple files.

Mutable collections in Freezed classes: Even though Freezed makes your class immutable, List and Map fields can still be mutated. Consider using immutable collections from the fast_immutable_collections package.

When to Use Freezed

Use Freezed when:

  • You want clean, immutable models for state management
  • You’re building complex state machines with multiple states
  • You need value equality for testing and caching
  • You’re working with APIs and need reliable JSON serialization
  • You want exhaustive pattern matching for handling all possible states

Consider alternatives when:

  • You have very simple data transfer objects
  • Build time is a critical concern
  • You prefer Dart 3’s native sealed classes (though Freezed offers more features)

Conclusion

Freezed is one of the best tools in a Flutter developer’s toolkit. It removes thousands of lines of repetitive boilerplate, introduces powerful sealed classes for state management, and makes your models easier to read, write, and maintain. The combination of immutability, value equality, pattern matching, and JSON serialization covers nearly every data modeling need in Flutter applications.

Start by converting your most-used models to Freezed and experience the productivity boost firsthand. Once you see how clean your code becomes, you’ll want to use Freezed for all your data classes.

For more on state management patterns, check out our guide on Provider vs Riverpod vs BLoC. For official documentation and advanced features, visit the Freezed package on pub.dev.

Leave a Comment