flutter
/

BLoC with Freezed: Immutable State & Union Types Made Easy

Last Sync: Today

On this page

11
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

BLoC with Freezed: Immutable State & Union Types Made Easy

Introduction

In BLoC, state classes should be immutable to ensure predictability and avoid unintended mutations. Writing immutable state classes manually leads to a lot of boilerplate: constructors, copyWith, ==, hashCode, and serialization code. Freezed is a code generator that creates all of this for you, and it also supports union types (sum types), making it perfect for representing different states like Loading, Success, Error. This guide covers how to integrate Freezed with your BLoC architecture.

Why Use Freezed with BLoC?

  • Immutability – Enforces immutable state by design.
  • Automatic copyWith – Update only specific fields without writing boilerplate.
  • Value Equality – Correct == and hashCode implementations for reliable state comparison.
  • Union Types – Model states as sealed classes (e.g., LoadingState, DataState, ErrorState).
  • JSON Serialization – Built‑in toJson/fromJson for persistence (e.g., with hydrated_bloc).
  • Reduced Boilerplate – Focus on what matters, not repetitive code.

Setting Up Freezed

Add the required packages to your pubspec.yaml:

YAMLRead-only
1
dependencies:
  flutter_bloc: ^8.1.5
  freezed_annotation: ^2.4.1
  json_annotation: ^4.8.1

dev_dependencies:
  build_runner: ^2.4.6
  freezed: ^2.4.5
  json_serializable: ^6.7.1

After writing your state classes with @freezed annotation, run:

flutter pub run build_runner build

This generates the required .freezed.dart and optionally .g.dart files for JSON serialization.

Basic Immutable State with Freezed

Here's a simple state class for a counter. Notice the @freezed annotation and the factory constructor.

DARTRead-only
1
import 'package:freezed_annotation/freezed_annotation.dart';

part 'counter_state.freezed.dart';

@freezed
class CounterState with _$CounterState {
  const factory CounterState({
    required int count,
    required bool isLoading,
  }) = _CounterState;
}

Freezed generates:

  • copyWith method for updating state immutably.
  • Overridden == and hashCode.
  • toString for debugging.

Using it in a BLoC:

DARTRead-only
1
class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc() : super(const CounterState(count: 0, isLoading: false)) {
    on<Increment>((event, emit) {
      emit(state.copyWith(count: state.count + 1));
    });
  }
}

Union Types (Sealed Classes)

Union types allow you to represent mutually exclusive states. For example, a typical data fetching state can be one of initial, loading, success, or error. Freezed makes this very clean.

DARTRead-only
1
import 'package:freezed_annotation/freezed_annotation.dart';

part 'posts_state.freezed.dart';

@freezed
class PostsState with _$PostsState {
  const factory PostsState.initial() = _PostsInitial;
  const factory PostsState.loading() = _PostsLoading;
  const factory PostsState.success(List<Post> posts) = _PostsSuccess;
  const factory PostsState.error(String message) = _PostsError;
}

Freezed generates helper methods to check the state type (isInitial, isLoading, etc.) and to access data safely (maybeMap, maybeWhen).

DARTRead-only
1
class PostsBloc extends Bloc<PostsEvent, PostsState> {
  PostsBloc() : super(const PostsState.initial()) {
    on<FetchPosts>(_onFetchPosts);
  }

  Future<void> _onFetchPosts(FetchPosts event, Emitter<PostsState> emit) async {
    emit(const PostsState.loading());
    try {
      final posts = await api.fetchPosts();
      emit(PostsState.success(posts));
    } catch (e) {
      emit(PostsState.error(e.toString()));
    }
  }
}

// In UI, you can use `when` or `map` to handle different states
BlocBuilder<PostsBloc, PostsState>(
  builder: (context, state) {
    return state.when(
      initial: () => const Center(child: Text('Press button')),
      loading: () => const Center(child: CircularProgressIndicator()),
      success: (posts) => ListView.builder(...),
      error: (message) => Center(child: Text('Error: $message')),
    );
  },
);

Adding JSON Serialization

Freezed integrates with json_serializable to generate toJson and fromJson methods. This is especially useful for hydrated_bloc to persist states.

DARTRead-only
1
import 'package:freezed_annotation/freezed_annotation.dart';

part 'settings_state.freezed.dart';
part 'settings_state.g.dart';

@freezed
class SettingsState with _$SettingsState {
  const factory SettingsState({
    required ThemeMode themeMode,
    required String language,
  }) = _SettingsState;

  factory SettingsState.fromJson(Map<String, dynamic> json) =>
      _$SettingsStateFromJson(json);
}

// Then in your BLoC, extend HydratedBloc and use toJson/fromJson
class SettingsBloc extends HydratedBloc<SettingsEvent, SettingsState> {
  SettingsBloc() : super(const SettingsState(themeMode: ThemeMode.system, language: 'en'));

  @override
  SettingsState? fromJson(Map<String, dynamic> json) => SettingsState.fromJson(json);

  @override
  Map<String, dynamic> toJson(SettingsState state) => state.toJson();
}

Advanced: Recursive & Generic States

Freezed supports recursive data structures and generics. For example, a tree structure or a generic state wrapper.

DARTRead-only
1
@freezed
class TreeNode with _$TreeNode {
  const factory TreeNode({
    required String id,
    required List<TreeNode> children,
  }) = _TreeNode;
}

@freezed
class ApiState<T> with _$ApiState<T> {
  const factory ApiState.initial() = _ApiInitial<T>;
  const factory ApiState.loading() = _ApiLoading<T>;
  const factory ApiState.success(T data) = _ApiSuccess<T>;
  const factory ApiState.error(String message) = _ApiError<T>;
}

Testing Freezed States

Because Freezed provides correct equality, testing becomes straightforward. You can compare expected and actual states directly.

DARTRead-only
1
blocTest<PostsBloc, PostsState>(
  'emits [loading, success] when fetch succeeds',
  build: () => PostsBloc(mockRepo),
  setUp: () => when(() => mockRepo.fetchPosts()).thenAnswer((_) async => [Post(id: 1)]),
  act: (bloc) => bloc.add(FetchPosts()),
  expect: () => [
    const PostsState.loading(),
    const PostsState.success([Post(id: 1)]),
  ],
);

Best Practices

  • Use union types for mutually exclusive states – Models like initial/loading/success/error are a perfect fit.
  • Keep states immutable – Freezed enforces this; never mutate fields after creation.
  • Leverage copyWith – Use it to update only the fields that changed.
  • Combine with hydrated_bloc – Freezed’s JSON serialization makes persistence easy.
  • Use when or map in UI – This ensures you handle all possible state variants (exhaustiveness).
  • Name constructors descriptively – initial, loading, success, error improve readability.

Common Mistakes

  • ❌ Forgetting to run build_runner – Results in missing generated files. ✅ Run flutter pub run build_runner build --delete-conflicting-outputs after changes.
  • ❌ Using mutable fields inside a Freezed class – Defeats immutability. ✅ Keep all fields final and use copyWith to create new instances.
  • ❌ Not using @immutable – Freezed doesn’t add it automatically, but it’s good practice to add it. ✅ Add @immutable annotation.
  • ❌ Ignoring union type exhaustiveness – In when, you must handle all cases; this is a feature, not a bug. ✅ Use maybeWhen if some cases can be ignored.
  • ❌ Using @freezed on classes that already have generics without proper configuration – Can cause issues. ✅ Read the Freezed documentation for advanced cases.

Conclusion

Freezed is a game‑changer for writing immutable, self‑documenting state classes in BLoC. It eliminates boilerplate, adds value equality, and enables union types that perfectly match the states of your BLoC. By integrating Freezed with hydrated_bloc and json_serializable, you can build robust, maintainable, and testable apps with minimal code.

Try it yourself

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

// Simple non-Freezed example for the interactive demo
class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);
  void increment() => emit(state + 1);
}

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: BlocProvider(
        create: (_) => CounterCubit(),
        child: CounterPage(),
      ),
    );
  }
}

class CounterPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final cubit = context.watch<CounterCubit>();
    return Scaffold(
      appBar: AppBar(title: Text('Freezed Demo Preview')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('Counter:', style: TextStyle(fontSize: 20)),
            Text('${cubit.state}', style: TextStyle(fontSize: 40)),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: cubit.increment,
              child: Text('Increment'),
            ),
            SizedBox(height: 20),
            Text('Freezed helps write immutable states with less code'),
          ],
        ),
      ),
    );
  }
}

Test Your Knowledge

Q1
of 3

What does Freezed generate for your state classes?

A
copyWith, ==, hashCode, toString
B
Only copyWith
C
Only JSON serialization
D
Only union types
Q2
of 3

How do you represent a state that can be loading, success, or error using Freezed?

A
With separate classes
B
With a single class and nullable fields
C
With union types (sealed classes)
D
With enums
Q3
of 3

What command do you run to generate Freezed code?

A
flutter run
B
flutter pub get
C
flutter pub run build_runner build
D
freezed generate

Frequently Asked Questions

Do I need to use Freezed for all BLoC states?

No, but it’s highly recommended because it enforces immutability and reduces boilerplate. For very simple states, you could use a plain class with Equatable, but Freezed scales better as your app grows.

Can I use Freezed with Cubit?

Yes, Freezed works exactly the same with Cubit. The state classes are independent of the state management approach.

How do I handle nested Freezed classes?

Freezed handles nested objects automatically if those objects also have copyWith and == (they can be Freezed classes themselves).

What is the difference between `@freezed` and `@unfreezed`?

@freezed generates a sealed class with union types. @unfreezed generates a plain class with copyWith, ==, etc., but without union types.

How do I migrate an existing BLoC state to Freezed?

Replace your state class with a Freezed‑annotated class, define all required factory constructors, and update your BLoC to use copyWith and union type checks. Then run build_runner to generate the code.

Previous

bloc widget testing

Next

bloc code generation

Related Content

Need help?

Explore our comprehensive docs or start a chat with our tech experts.