flutter
/

BLoC State Immutability: Why It Matters & How to Enforce It

Last Sync: Today

On this page

8
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

BLoC State Immutability: Why It Matters & How to Enforce It

Introduction

Immutability is a cornerstone of the BLoC pattern. An immutable state cannot be changed after it’s created; any modification produces a new state instance. This aligns perfectly with BLoC’s reactive nature, where state changes are explicit and predictable. This guide explains why immutability is essential in BLoC, how to enforce it using plain Dart, Equatable, and Freezed, and common pitfalls to avoid.

Why Immutability Matters in BLoC

  • Predictability – State changes are explicit; you can trace exactly what changed and when.
  • Performance – BLoC (and Flutter) can compare old and new states using ==. If state is mutable, equality checks can break, causing unnecessary rebuilds or missed updates.
  • Debugging – Immutable states make it easier to understand the flow because you can see the exact state at any point.
  • Thread Safety – In a reactive environment, multiple parts of the code may access the same state object; immutability eliminates accidental mutations.
  • Time Travel / Redux DevTools – Immutable states are essential for debugging tools that replay state changes.

How to Enforce Immutability in Dart

Mark all state fields as final. This prevents reassignment after construction.

DARTRead-only
1
class CounterState {
  final int count;
  final bool isLoading;

  const CounterState({required this.count, required this.isLoading});
}

To update state, you need a way to create a new instance with modified fields. Implement a copyWith method manually or use code generation.

DARTRead-only
1
class CounterState {
  final int count;
  final bool isLoading;

  const CounterState({required this.count, required this.isLoading});

  CounterState copyWith({int? count, bool? isLoading}) {
    return CounterState(
      count: count ?? this.count,
      isLoading: isLoading ?? this.isLoading,
    );
  }
}

For proper state comparison (used by BlocBuilder), you must override equality. Equatable makes this trivial.

DARTRead-only
1
class CounterState extends Equatable {
  final int count;
  final bool isLoading;

  const CounterState({required this.count, required this.isLoading});

  @override
  List<Object?> get props => [count, isLoading];
}

Using Equatable for Immutable States

Equatable is the standard way to implement value equality in BLoC states. It reduces boilerplate and ensures correct state comparison.

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

class UserState extends Equatable {
  final String name;
  final int age;

  const UserState({required this.name, required this.age});

  @override
  List<Object?> get props => [name, age];

  UserState copyWith({String? name, int? age}) {
    return UserState(
      name: name ?? this.name,
      age: age ?? this.age,
    );
  }
}

// Usage in BLoC
emit(state.copyWith(name: 'John'));

Using Freezed for Full Immutability

Freezed is a code generator that creates immutable classes with copyWith, ==, toString, and union types. It’s the most robust way to handle immutable states in BLoC.

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

part 'user_state.freezed.dart';

@freezed
class UserState with _$UserState {
  const factory UserState({
    required String name,
    required int age,
  }) = _UserState;
}

// Usage in BLoC
emit(state.copyWith(name: 'Jane'));

Common Mistakes with Immutability

  • ❌ Mutating state fields directly – e.g., state.count++ after emitting. ✅ Always create a new state with copyWith.
  • ❌ Forgetting to override == – Causes BlocBuilder to rebuild unnecessarily or miss updates. ✅ Use Equatable or generate equality.
  • ❌ Using mutable collections – Lists, maps inside state can be modified externally. ✅ Use List.unmodifiable or copy collections on update.
  • ❌ Creating new states with the same values – Leads to unnecessary rebuilds. ✅ Emit only when state actually changes (check before emitting).
  • ❌ Not using final fields – Fields can be reassigned accidentally. ✅ Mark all fields final.

Best Practices

  • Always use Equatable or Freezed – Ensures correct equality and reduces boilerplate.
  • Implement copyWith – Provides a clean way to derive new states.
  • Keep states small – Break down large states into smaller, focused states.
  • Avoid deep nested objects – Use immutable collections (e.g., List.unmodifiable).
  • Don’t emit identical states – Check with if (newState != state) before emitting.
  • Use @immutable annotation – Helps the analyzer catch mutable fields.
  • Prefer Freezed for complex states – Union types, JSON serialization, and full immutability.

Conclusion

Immutability is non‑negotiable in BLoC. It ensures predictable state updates, simplifies debugging, and prevents subtle bugs. By using final fields, copyWith, and value equality (Equatable or Freezed), you can enforce immutability with minimal effort. Adopting these practices will make your BLoCs more reliable and maintainable.

Try it yourself

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

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

// Immutable state with Equatable
class CounterState extends Equatable {
  final int count;
  final bool isLoading;

  const CounterState({required this.count, this.isLoading = false});

  CounterState copyWith({int? count, bool? isLoading}) {
    return CounterState(
      count: count ?? this.count,
      isLoading: isLoading ?? this.isLoading,
    );
  }

  @override
  List<Object?> get props => [count, isLoading];
}

// Events
abstract class CounterEvent {}
class Increment extends CounterEvent {}
class StartLoading extends CounterEvent {}
class StopLoading extends CounterEvent {}

// BLoC
class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc() : super(const CounterState(count: 0)) {
    on<Increment>((event, emit) {
      emit(state.copyWith(count: state.count + 1));
    });
    on<StartLoading>((event, emit) {
      emit(state.copyWith(isLoading: true));
    });
    on<StopLoading>((event, emit) {
      emit(state.copyWith(isLoading: false));
    });
  }
}

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

class CounterPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Immutable State Demo')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            BlocBuilder<CounterBloc, CounterState>(
              builder: (context, state) {
                if (state.isLoading) {
                  return const CircularProgressIndicator();
                }
                return Text('Count: ${state.count}', style: TextStyle(fontSize: 40));
              },
            ),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: () => context.read<CounterBloc>().add(Increment()),
              child: const Text('Increment'),
            ),
            ElevatedButton(
              onPressed: () => context.read<CounterBloc>().add(StartLoading()),
              child: const Text('Simulate Loading'),
            ),
            ElevatedButton(
              onPressed: () => context.read<CounterBloc>().add(StopLoading()),
              child: const Text('Stop Loading'),
            ),
          ],
        ),
      ),
    );
  }
}

Test Your Knowledge

Q1
of 3

Why is immutability important in BLoC?

A
It makes the code faster
B
It ensures predictable state changes and proper equality comparisons
C
It is required by the Flutter framework
D
It reduces memory usage
Q2
of 3

How do you update an immutable state in a BLoC?

A
By modifying fields directly
B
By creating a new state instance using copyWith
C
By reassigning the state variable
D
By calling emit with the same object
Q3
of 3

Which package helps implement value equality in BLoC states?

A
flutter_bloc
B
equatable
C
freezed
D
Both B and C

Frequently Asked Questions

Why can’t I just mutate the state object directly?

BLoC compares states using == to decide whether to rebuild. If you mutate the same object, the reference remains the same and == returns true, so BlocBuilder won’t rebuild even though data changed. Also, mutability can cause race conditions and hard‑to‑debug issues.

Is Equatable required for every state?

It’s not strictly required, but without it you must manually override == and hashCode. Equatable makes this trivial and is the recommended way in the BLoC community.

How do I handle lists and maps immutably?

Use List.unmodifiable or Map.unmodifiable. When updating, create a new list/map with the changes (e.g., [...oldList, newItem]). Freezed also provides built‑in support for immutable collections.

Can I use Freezed with `hydrated_bloc`?

Yes, Freezed generates toJson and fromJson methods if you add json_serializable, making it perfect for hydrated_bloc.

What’s the difference between `Equatable` and `Freezed`?

Equatable only provides value equality. Freezed generates a full immutable class with copyWith, toString, equality, and optionally union types and JSON serialization. Freezed is more powerful but requires code generation.

Previous

bloc vs riverpod

Next

bloc state copywith

Related Content

Need help?

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