Immutable models with freezed: Data classes done right
Writing data classes in Dart can feel repetitive. You need constructors, equality checks, toString implementations, and copy methods for every model you create. Freezed eliminates that boilerplate while giving you immutable models with type safety out of the box. By day 39 of our Flutter journey, you've likely built enough features to feel the pain of manual model maintenance—freezed is the antidote.
Why immutability matters in production apps
Immutable models prevent accidental state mutations, which are a source of subtle bugs in large codebases. When a model cannot change after creation, you gain predictability. Functions that receive immutable objects can't have side effects through those objects. In trading systems or real-time e-commerce platforms I've built, this predictability is critical. A user's cart shouldn't mysteriously mutate during a concurrent API call.
Freezed enforces immutability by code generation. It creates private final fields and prevents any post-construction modifications. This pairs perfectly with state management solutions like Bloc and Riverpod, where state should never be mutated—only replaced.
copyWith for updates
The most frustrating part of working with immutable objects is creating modified copies. Without a helper method, you'd write code like this:
class User {
final String name;
final String email;
final int age;
User({required this.name, required this.email, required this.age});
User copyWith({
String? name,
String? email,
int? age,
}) {
return User(
name: name ?? this.name,
email: email ?? this.email,
age: age ?? this.age,
);
}
}
// Usage
final updatedUser = user.copyWith(email: 'new@example.com');Freezed generates copyWith automatically. Here's the equivalent with freezed:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
@freezed
class User with _$User {
const factory User({
required String name,
required String email,
required int age,
}) = _User;
factory User.fromJson(Map json) => _$UserFromJson(json);
}
// Usage is identical
final updatedUser = user.copyWith(email: 'new@example.com');The generated copyWith method is null-aware and type-safe. You only provide fields you want to change. In a CRM system, when updating contact details after an API response, this pattern becomes invaluable. You're not rewriting constructors across dozens of models.
Value equality out of the box
Dart objects use reference equality by default. Two User instances with identical data are not equal unless they're the same object in memory.
// Without freezed
final user1 = User(name: 'Alice', email: 'alice@test.com', age: 28);
final user2 = User(name: 'Alice', email: 'alice@test.com', age: 28);
print(user1 == user2); // false - different objectsYou'd typically override operator== and hashCode manually. Freezed does this for you based on field values:
// With freezed
final user1 = User(name: 'Alice', email: 'alice@test.com', age: 28);
final user2 = User(name: 'Alice', email: 'alice@test.com', age: 28);
print(user1 == user2); // true - value equalityValue equality matters when comparing states in Bloc. If your state object hasn't truly changed, Bloc won't rebuild the UI—avoiding unnecessary widget rebuilds. In a ride-hailing app with frequent location updates, this optimization prevents janky maps and excessive repaints.
Union types for states
Freezed shines when modeling state variants. In Bloc, you often have multiple state classes: loading, success, error, initial. Freezed's union types (sealed unions via factory constructors) express this elegantly:
@freezed
class UserState with _$UserState {
const factory UserState.initial() = _Initial;
const factory UserState.loading() = _Loading;
const factory UserState.success(User user) = _Success;
const factory UserState.error(String message) = _Error;
}
// Pattern matching in UI
@override
Widget build(BuildContext context, UserBloc bloc) {
return bloc.state.when(
initial: () => Center(child: Text('Start here')),
loading: () => const CircularProgressIndicator(),
success: (user) => UserProfile(user: user),
error: (message) => ErrorWidget(message: message),
);
}The when method is exhaustive. If you add a new state variant, the compiler forces you to handle it everywhere. This prevents runtime crashes from missing state cases. In a travel booking app, you might have states for selecting flights, confirming seats, processing payment, and handling network errors. Union types make these flows explicit and safe.
Pairs well with Bloc and Riverpod
Freezed models are the natural data structure for state management. With Bloc, you define your state as a freezed union type, and the generated when method gives you compile-time exhaustiveness checking.
// In your Bloc
class ProductBloc extends Bloc {
ProductBloc() : super(const ProductState.initial()) {
on(_onFetchProducts);
}
Future _onFetchProducts(
FetchProducts event,
Emitter emit,
) async {
emit(const ProductState.loading());
try {
final products = await _repository.getProducts();
emit(ProductState.success(products));
} catch (e) {
emit(ProductState.error(e.toString()));
}
}
}
// In your widget
BlocBuilder(
builder: (context, state) {
return state.when(
initial: () => const SizedBox(),
loading: () => const Loader(),
success: (products) => ProductList(products: products),
error: (message) => ErrorMessage(message: message),
);
},
)With Riverpod, you use freezed for the data your providers expose. The immutability guarantee means families are stable and caching works predictably:
final userProvider = FutureProvider.family((ref, userId) async {
return ref.watch(apiServiceProvider).getUser(userId);
});
// In your widget, the AsyncValue is also a freezed union
ref.watch(userProvider('123')).when(
data: (user) => UserCard(user: user),
loading: () => const Skeleton(),
error: (err, stack) => ErrorDisplay(error: err),
);Common mistakes
Forgetting the part directives. Freezed requires part 'filename.freezed.dart'; and optionally part 'filename.g.dart'; for JSON serialization. Without these, code generation fails silently.
Mixing mutable and immutable fields. Don't add mutable fields to freezed classes. If you need a field that changes, move it outside the model or use a wrapper type. Freezed's strength is enforced immutability—fighting it defeats the purpose.
Not running build_runner. After defining a freezed class, you must run flutter pub run build_runner build or flutter pub run build_runner watch. Skipping this leaves you with incomplete generated code and compilation errors.
Over-nesting union types. While freezed handles nested unions, deeply nested state models become hard to pattern match. Keep union types at one or two levels. Use separate classes for complex data within a success state rather than embedding everything in the union.
Key takeaways
- Freezed eliminates boilerplate for immutable models, generating constructors, equality, toString, and copyWith automatically.
- copyWith provides a type-safe, null-aware way to create modified copies of immutable objects without constructor sprawl.
- Value equality out of the box prevents unnecessary rebuilds in Bloc and Riverpod—a critical optimization for high-frequency state updates.
- Union types (sealed variants) express state machines explicitly, with exhaustive pattern matching via when that catches missing cases at compile time.
- Freezed pairs seamlessly with state management frameworks, making Bloc events and Riverpod providers cleaner and safer.
- Always run build_runner watch during development and commit generated .freezed.dart files to version control.
- Enforce immutability across your data layer—it's not just a best practice, it's the foundation of predictable, scalable apps.






Comments
Loading comments…