Understanding Equatable in Flutter: A Practical Guide

Understanding Equatable in Flutter: A Practical Guide

Tags
Flutter
Series
Published
June 29, 2025
Platform
Medium
Medium
Medium
Author
Ruby Chu
Object comparison in Flutter might seem straightforward initially, but it can lead to subtle bugs if you don’t understand how it works under the hood. This guide will explore the Equatable package, why you might need it, and how to use it effectively.

The Problem with Default Object Comparison

By default, Flutter (and Dart) compares objects based on their memory locations rather than their content. Here’s a simple analogy: imagine you have two identical phones — same model, same color, everything. Flutter treats them as different objects simply because one is in your left pocket and the other in your right pocket. This isn’t always what we want in our applications.

When Do You Need Equatable?

Let’s explore three common scenarios where Equatable becomes invaluable:

1. State Management with Bloc/Cubit

When using Bloc or Cubit for state management, proper object comparison is crucial for efficient UI updates. Let’s dive into a complete example of managing a user profile feature:
// First, define events that our Bloc will handle abstract class UserProfileEvent extends Equatable { const UserProfileEvent(); @override List<Object> get props => []; } class LoadUserProfile extends UserProfileEvent { final String userId; const LoadUserProfile(this.userId); @override List<Object> get props => [userId]; // Include userId in comparison } class UpdateUserName extends UserProfileEvent { final String newName; const UpdateUserName(this.newName); @override List<Object> get props => [newName]; } // Define possible states abstract class UserProfileState extends Equatable { const UserProfileState(); } class UserProfileInitial extends UserProfileState { @override List<Object> get props => []; } class UserProfileLoading extends UserProfileState { @override List<Object> get props => []; } class UserProfileLoaded extends UserProfileState { final String name; final int age; final String email; final bool isPremium; const UserProfileLoaded({ required this.name, required this.age, required this.email, required this.isPremium, }); @override List<Object> get props => [name, age, email, isPremium]; // Helper method to create new instances with modified data UserProfileLoaded copyWith({ String? name, int? age, String? email, bool? isPremium, }) { return UserProfileLoaded( name: name ?? this.name, age: age ?? this.age, email: email ?? this.email, isPremium: isPremium ?? this.isPremium, ); } } class UserProfileError extends UserProfileState { final String message; const UserProfileError(this.message); @override List<Object> get props => [message]; }
// Implement the Bloc class UserProfileBloc extends Bloc<UserProfileEvent, UserProfileState> { final UserRepository userRepository; UserProfileBloc({required this.userRepository}) : super(UserProfileInitial()) { on<LoadUserProfile>(_onLoadUserProfile); on<UpdateUserName>(_onUpdateUserName); } Future<void> _onLoadUserProfile( LoadUserProfile event, Emitter<UserProfileState> emit, ) async { emit(UserProfileLoading()); try { final user = await userRepository.getUser(event.userId); emit(UserProfileLoaded( name: user.name, age: user.age, email: user.email, isPremium: user.isPremium, )); } catch (e) { emit(UserProfileError('Failed to load user profile: ${e.toString()}')); } } Future<void> _onUpdateUserName( UpdateUserName event, Emitter<UserProfileState> emit, ) async { final currentState = state; if (currentState is UserProfileLoaded) { try { // Optimistic update emit(currentState.copyWith(name: event.newName)); // Perform the actual update await userRepository.updateUserName(event.newName); } catch (e) { // Revert to previous state on error emit(currentState); emit(UserProfileError('Failed to update name: ${e.toString()}')); } } } }
// Usage in UI class UserProfilePage extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder<UserProfileBloc, UserProfileState>( builder: (context, state) { if (state is UserProfileLoading) { return CircularProgressIndicator(); } if (state is UserProfileLoaded) { return Column( children: [ Text('Name: ${state.name}'), Text('Age: ${state.age}'), Text('Email: ${state.email}'), Text('Premium: ${state.isPremium}'), ElevatedButton( onPressed: () { context.read<UserProfileBloc>().add( UpdateUserName('New Name'), ); }, child: Text('Update Name'), ), ], ); } if (state is UserProfileError) { return Text('Error: ${state.message}'); } return Container(); }, ); } }
Let’s break down why Equatable is crucial in this Bloc implementation:
  1. State Comparison: When the Bloc emits a new state, the framework compares it with the previous state. Without Equatable, two UserProfileLoaded states with identical data would be considered different, causing unnecessary UI rebuilds.
  1. Event Handling: Equatable ensures that identical events (like loading the same user ID twice) are properly identified. This can be useful for event debouncing or duplicate event prevention.
  1. State Updates: The copyWith method in UserProfileLoaded creates new state instances. Equatable helps verify that the new state is different from the current one.
  1. Error Handling: Even error states benefit from Equatable. Two error states with the same message are considered equal, preventing duplicate error displays.
Best Practices:
  1. Always include all relevant fields in the props getter
  1. Make state classes immutable using final fields
  1. Implement copyWith methods for complex states
  1. Use abstract base classes for events and states
  1. Consider using const constructors for states and events
Common Pitfalls:
  1. Forgetting Fields: Missing fields in props can cause comparison issues
  1. Mutable Objects: Including mutable objects in props can lead to unexpected behavior
  1. Unnecessary Props: Including irrelevant fields can cause excessive rebuilds
  1. Late Fields: Be careful with late fields in props as they might not be initialized

2. Working with Collections

Sets in Dart are designed to store unique items. However, without Equatable, they might not work as expected:
final items = <Product>{ Product('apple', 1.99), Product('apple', 1.99) // Both items stay in the set without Equatable }; print(items.length); // Outputs: 2
// With Equatable, identical products are correctly identified as duplicates final items = <Product>{ Product('apple', 1.99), Product('apple', 1.99) // This item is removed as a duplicate }; print(items.length); // Outputs: 1

3. Testing

Equatable makes your tests more reliable and intuitive:
test('user login test', () { final expected = UserData('john', 25); final result = loginUser('john', 25); expect(result, expected); // Fails without Equatable, works with it });
 
Without Equatable, your tests might fail even when the data is identical, simply because the objects are in different memory locations.

Implementation Guide

  1. First, add Equatable to your pubspec.yaml:
dependencies: equatable: ^2.0.5
2. Create an Equatable class:
class Phone extends Equatable { final String model; final String color; Phone(this.model, this.color); @override List<Object> get props => [model, color]; // List the properties to compare }

Conclusion

Equatable is a powerful tool in Flutter development that helps you implement proper object comparison based on content rather than memory location. It’s particularly useful in state management, working with collections, and testing. By understanding when and how to use it, you can write more predictable and maintainable code.
Remember: Use Equatable when you need to compare objects based on their values rather than their identity. It’s especially valuable in state management and testing scenarios where proper object comparison is crucial for your application’s behavior.