flutter
/

Bloc States: Designing Immutable State Classes

Last Sync: Today

On this page

12
0%
Intermediate
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutterIntermediate

Bloc States: Designing Immutable State Classes

In Bloc, states are the source of truth for your UI. They represent what the user sees at any given moment. Designing clean, immutable state classes is crucial for predictable behavior, testability, and performance. This guide covers everything you need to know about states in Bloc.

What is a State in Bloc?

A state is an object that holds data and reflects the current condition of a feature. For example, a LoginState could be initial, loading, success, or failure. The Bloc emits states in response to events, and the UI rebuilds when a new state is received.

Immutability: Why It Matters

States must be immutable. Once created, their properties cannot change. Instead, you create a new instance when you want to update the state. This approach has several benefits:

  • Predictability – No unexpected side effects.
  • Performance – Bloc can efficiently compare old and new states using == to decide whether to rebuild.
  • Debugging – You can track state changes over time (time travel).

Using Equatable

Equatable overrides == and hashCode for you, so you don’t have to write boilerplate. Extend Equatable in your state classes and list the properties in the props getter.

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

abstract class LoginState extends Equatable {
  const LoginState();

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

class LoginInitial extends LoginState {}

class LoginLoading extends LoginState {}

class LoginSuccess extends LoginState {
  final String userId;
  const LoginSuccess(this.userId);

  @override
  List<Object?> get props => [userId];
}

class LoginFailure extends LoginState {
  final String message;
  const LoginFailure(this.message);

  @override
  List<Object?> get props => [message];
}

Common State Patterns

The most common pattern for asynchronous operations (API calls). Define states for each phase.

DARTRead-only
1
abstract class DataState extends Equatable {}

class DataInitial extends DataState {}

class DataLoading extends DataState {}

class DataLoaded extends DataState {
  final List<Item> items;
  DataLoaded(this.items);

  @override
  List<Object?> get props => [items];
}

class DataError extends DataState {
  final String error;
  DataError(this.error);

  @override
  List<Object?> get props => [error];
}

When data can be empty, use an explicit empty state to show a friendly message.

DARTRead-only
1
class DataEmpty extends DataState {}

// In bloc:
emit(DataEmpty()); // when API returns empty list

Sometimes you need to combine multiple pieces of state. Instead of one huge state class with many nullable fields, use sealed classes (or union types) to represent mutually exclusive states.

DARTRead-only
1
sealed class UserProfileState {}

final class UserProfileLoading extends UserProfileState {}

final class UserProfileLoaded extends UserProfileState {
  final User user;
  final List<Post> posts;
  UserProfileLoaded(this.user, this.posts);
}

final class UserProfileError extends UserProfileState {
  final String message;
  UserProfileError(this.message);
}

State with Multiple Properties

When a state holds multiple pieces of data, you might be tempted to use a single class with nullable fields. But that can lead to invalid combinations (e.g., both loading and data at the same time). Prefer separate state classes or use sealed classes.

DARTRead-only
1
// ❌ Avoid: nullable fields that can be in invalid states
class CombinedState {
  final bool isLoading;
  final List<Item>? items;
  final String? error;
  // Possible invalid states: isLoading = true and items != null
}

// ✅ Prefer: distinct state classes
abstract class ItemsState {}
class ItemsLoading extends ItemsState {}
class ItemsLoaded extends ItemsState {
  final List<Item> items;
  ItemsLoaded(this.items);
}
class ItemsError extends ItemsState {
  final String error;
  ItemsError(this.error);
}

State Inheritance and Reusability

If several states share common properties, use an abstract base class that extends Equatable and include those properties in props.

DARTRead-only
1
abstract class FormState extends Equatable {
  final bool isValid;
  final String? errorMessage;
  const FormState(this.isValid, this.errorMessage);

  @override
  List<Object?> get props => [isValid, errorMessage];
}

class FormInitial extends FormState {
  const FormInitial() : super(false, null);
}

class FormValid extends FormState {
  const FormValid() : super(true, null);
}

class FormError extends FormState {
  const FormError(String errorMessage) : super(false, errorMessage);
}

Performance Considerations

  • Use Equatable – Prevents unnecessary rebuilds by properly comparing states.
  • Keep states small – Large state objects can slow down equality checks. Split them if possible.
  • Avoid storing large lists in multiple states – If a list is only needed in one state, don't include it in others.
  • Use copyWith for immutable updates – Helpful when states have many properties, but prefer separate state classes when possible.

Implementing copyWith (for states with many fields)

If you have a state with many fields and you need to update only one, you can implement a copyWith method.

DARTRead-only
1
class UserSettingsState extends Equatable {
  final bool darkMode;
  final bool notifications;
  final String language;

  const UserSettingsState({
    required this.darkMode,
    required this.notifications,
    required this.language,
  });

  UserSettingsState copyWith({
    bool? darkMode,
    bool? notifications,
    String? language,
  }) {
    return UserSettingsState(
      darkMode: darkMode ?? this.darkMode,
      notifications: notifications ?? this.notifications,
      language: language ?? this.language,
    );
  }

  @override
  List<Object?> get props => [darkMode, notifications, language];
}

// Usage:
emit(state.copyWith(darkMode: true));

Best Practices

  • Use sealed classes or abstract base + implementations – Clearly represent distinct states.
  • Always use Equatable – Avoid manual == and hashCode.
  • Make states immutable – Use final fields and create new instances for changes.
  • Name states clearly – LoginLoading, LoginSuccess, LoginFailure are self‑documenting.
  • Keep states flat – Avoid deep nesting; use distinct classes for different phases.
  • Test state equality – Write tests to ensure Equatable works as expected.

Common Mistakes

  • ❌ Mutating state properties – Leads to bugs and no UI updates. ✅ Always create new instances.
  • ❌ Forgetting to add Equatable – Causes unnecessary rebuilds because states are never considered equal. ✅ Extend Equatable and list props.
  • ❌ Using nullable fields to represent multiple states – Creates invalid combinations. ✅ Use separate state classes.
  • ❌ Storing large lists in all states – Wastes memory. ✅ Keep data only in the states that need it.
  • ❌ Not handling empty states – UI shows nothing when data is empty. ✅ Add explicit empty state.

Next Steps

Now that you understand state design, learn how to combine them with events and blocs:

  • Bloc Core Concepts
  • Cubit vs Bloc
  • Bloc Architecture

Conclusion

States are the foundation of Bloc’s reactivity. By designing immutable, well‑structured state classes, you ensure your app behaves predictably and remains maintainable. Use Equatable, separate state classes for different scenarios, and follow the best practices to build robust Flutter applications.

Try it yourself

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

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

// States
abstract class CounterState extends Equatable {
  const CounterState();

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

class CounterInitial extends CounterState {}

class CounterLoading extends CounterState {}

class CounterValue extends CounterState {
  final int value;
  const CounterValue(this.value);

  @override
  List<Object?> get props => [value];
}

class CounterError extends CounterState {
  final String message;
  const CounterError(this.message);

  @override
  List<Object?> get props => [message];
}

// Cubit
class CounterCubit extends Cubit<CounterState> {
  CounterCubit() : super(CounterInitial());

  void fetchData() async {
    emit(CounterLoading());
    await Future.delayed(const Duration(seconds: 1));
    emit(CounterValue(42));
  }

  void simulateError() {
    emit(CounterError('Something went wrong!'));
  }
}

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) {
    return Scaffold(
      appBar: AppBar(title: Text('Bloc States Demo')),
      body: Center(
        child: BlocBuilder<CounterCubit, CounterState>(
          builder: (context, state) {
            if (state is CounterLoading) {
              return const CircularProgressIndicator();
            }
            if (state is CounterValue) {
              return Text('Value: ${state.value}', style: const TextStyle(fontSize: 24));
            }
            if (state is CounterError) {
              return Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Text('Error: ${state.message}', style: const TextStyle(color: Colors.red)),
                  const SizedBox(height: 10),
                  ElevatedButton(
                    onPressed: () => context.read<CounterCubit>().fetchData(),
                    child: const Text('Retry'),
                  ),
                ],
              );
            }
            return Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Text('No data yet'),
                const SizedBox(height: 10),
                ElevatedButton(
                  onPressed: () => context.read<CounterCubit>().fetchData(),
                  child: const Text('Fetch Data'),
                ),
                ElevatedButton(
                  onPressed: () => context.read<CounterCubit>().simulateError(),
                  child: const Text('Simulate Error'),
                ),
              ],
            );
          },
        ),
      ),
    );
  }
}

Test Your Knowledge

Q1
of 3

Why should states be immutable in Bloc?

A
To prevent memory leaks
B
To enable predictable state changes and efficient UI rebuilding
C
To make code compile faster
D
To use Equatable
Q2
of 3

What is the purpose of Equatable in state classes?

A
To make states serializable
B
To automatically implement `==` and `hashCode` for value-based equality
C
To generate copyWith methods
D
To emit states asynchronously
Q3
of 3

Which pattern is best for representing loading, success, and error states?

A
A single class with isLoading, data, and error nullable fields
B
Three separate state classes (Loading, Loaded, Error)
C
A map of status codes
D
Using a global variable

Frequently Asked Questions

Do I always need to use Equatable with states?

Not strictly, but it's highly recommended. Without it, Bloc may rebuild the UI even when the state hasn't logically changed, because the default == compares references, not values. Equatable makes equality checks based on the state's data, preventing unnecessary rebuilds.

Should I use sealed classes or an abstract class with multiple implementations?

Sealed classes (introduced in Dart 3.0) are the best choice because they ensure exhaustive checking in switch statements. If you're on an older Dart version, use an abstract class with @immutable and multiple concrete classes.

How do I handle states that combine multiple independent data sets?

You can have a single state class that contains multiple fields, but ensure they are all required and the class is immutable. Use copyWith for updates. For complex scenarios, consider splitting into separate blocs.

What's the difference between `Equatable` and `freezed`?

freezed is a code generator that creates immutable classes, copyWith, toString, and more. It automatically handles equality. Equatable is simpler – it only overrides == and hashCode. For complex states, freezed is powerful; for simple ones, Equatable suffices.

How do I test state equality in Bloc tests?

Use expect with blocTest and compare states. If you use Equatable, you can compare states directly. For example: expect(state, LoginSuccess(userId: '123')).

Previous

bloc events

Next

bloc bloc class

Related Content

Need help?

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