← Back to posts Cover image for Mastering Immutability in Flutter: Best Practices for Cleaner, Predictable State

Mastering Immutability in Flutter: Best Practices for Cleaner, Predictable State

· 5 min read
Weekly Digest

The Flutter news you actually need

No spam, ever. Unsubscribe in one click.

Chris
By Chris

Immutability is one of those concepts that sounds academic until you experience its benefits firsthand in a Flutter app. An immutable object is one whose state cannot be modified after it is created. Instead of mutating it, you create a new instance with the desired changes. This leads to cleaner, more predictable state management, easier debugging, and safer concurrency. However, Dart’s approach to immutability has some rough edges, especially when dealing with collections. Let’s explore how to master it in practice.

Why Immutability Matters in Flutter

Flutter’s UI is a function of state: UI = f(state). When your state is immutable, you gain several key advantages:

  1. Predictability: A state object can only be in the condition it was created in. You never have to worry about it changing unexpectedly from another part of your code.
  2. Performance with const: The Flutter framework can heavily optimize const widgets. Immutable data models are natural candidates for const constructors.
  3. Simplified Change Detection: With immutable state, checking if something changed becomes a trivial reference equality check (oldState == newState). This is a cornerstone of efficient state management libraries like Provider, Riverpod, and Bloc.

The Basic Pattern and Its Immediate Pitfall

Let’s start with a simple immutable data class for a user profile:

class UserProfile {
  final String name;
  final int age;
  final List<String> hobbies;

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

Looks good, right? All fields are final. However, there’s a critical flaw here. While the hobbies reference is final, the List it points to is not immutable.

void main() {
  final myHobbies = ['reading', 'hiking'];
  final user = UserProfile(name: 'Alice', age: 30, hobbies: myHobbies);

  // This modifies the underlying list, violating immutability!
  myHobbies.add('gaming');
  print(user.hobbies); // Output: [reading, hiking, gaming]
}

The UserProfile instance was contaminated from the outside. This is the most common pitfall developers encounter with immutability in Dart.

Practical Solutions for True Immutability

1. Defensive Copying

The simplest fix is to never store the original mutable collection. Instead, create a defensive copy upon construction and when returning the collection.

class UserProfile {
  final String name;
  final int age;
  final List<String> _hobbies;

  UserProfile({
    required this.name,
    required this.age,
    required List<String> hobbies,
  }) : _hobbies = List<String>.unmodifiable(hobbies);
      // Or: _hobbies = List.of(hobbies);

  // Return an unmodifiable view to protect the internal list
  List<String> get hobbies => List.unmodifiable(_hobbies);
}

Now, the internal list is protected. However, this approach becomes verbose quickly, especially when you need a copyWith method.

2. Implementing a Robust copyWith

For an immutable class to be useful, you need an easy way to create a modified copy. This is where the copyWith pattern shines.

import 'package:collection/collection.dart';

class UserProfile {
  final String name;
  final int age;
  final List<String> _hobbies;

  UserProfile({
    required this.name,
    required this.age,
    required List<String> hobbies,
  }) : _hobbies = List.of(hobbies);

  List<String> get hobbies => List.unmodifiable(_hobbies);

  UserProfile copyWith({
    String? name,
    int? age,
    List<String>? hobbies,
  }) {
    return UserProfile(
      name: name ?? this.name,
      age: age ?? this.age,
      hobbies: hobbies ?? _hobbies, // A new list is provided, or the old one is reused
    );
  }

  // Don't forget to override == and hashCode for value equality!
  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is UserProfile &&
          runtimeType == other.runtimeType &&
          name == other.name &&
          age == other.age &&
          const ListEquality().equals(_hobbies, other._hobbies);

  @override
  int get hashCode => Object.hash(name, age, const ListEquality().hash(_hobbies));
}

Use it like this:

final updatedUser = originalUser.copyWith(
  age: 31,
  hobbies: [...originalUser.hobbies, 'cycling'], // Create a new list with spread
);

Writing all this boilerplate—defensive copies, copyWith, ==, hashCode—for every model class is tedious and error-prone. This is where code generation tools become essential.

3. Leveraging Code Generation with Freezed

The freezed package is the community-standard solution for this problem. It generates all the boilerplate for you, ensuring correctness and saving immense time.

// pubspec.yaml: add freezed and build_runner as dev_dependencies

import 'package:freezed_annotation/freezed_annotation.dart';

part 'user_profile.freezed.dart';

@freezed
class UserProfile with _$UserProfile {
  const factory UserProfile({
    required String name,
    required int age,
    @Default([]) List<String> hobbies, // Freezed handles immutability automatically
  }) = _UserProfile;
}

After running dart run build_runner build, you get a class that:

  • Has all fields as final.
  • Provides a perfect copyWith method (e.g., user.copyWith(age: 31)).
  • Correctly implements == and hashCode with deep collection equality.
  • Handles JSON serialization easily (with json_serializable).
  • Protects collections from external modification.

It completely eliminates the pitfalls and boilerplate, letting you focus on your app’s logic.

Best Practices Summary

  1. Make all fields final. This is the non-negotiable first step.
  2. Never trust external collections. Always create defensive copies using List.of(), Map.of(), Set.of(), or List.unmodifiable().
  3. Provide a copyWith method. It’s the primary way to “modify” immutable objects.
  4. Override == and hashCode. Use package:collection’s ListEquality for deep collection comparison if not using code generation.
  5. Embrace code generation. For any non-trivial app, using freezed is the most efficient and reliable path to full immutability. The initial setup pays for itself many times over.

By embracing these patterns, you’ll create Flutter applications where state flows predictably, bugs related to unintended mutations vanish, and your overall architecture becomes significantly more robust.

This blog is produced with the assistance of AI by a human editor. Learn more

Related Posts

Cover image for Optimizing Flutter UI Performance: Best Practices for Date Formatting and Expensive Operations

Optimizing Flutter UI Performance: Best Practices for Date Formatting and Expensive Operations

Developers often face performance bottlenecks when performing expensive operations like date formatting directly within Flutter's `build` method, especially in fast-scrolling lists. This post will delve into common pitfalls, explain why these operations are costly, and provide practical strategies for optimizing UI performance by caching formatters, using `initState`, and leveraging `compute` for background processing without blocking the UI.

Cover image for Optimizing Your Flutter Dev Setup: IDEs, Simulators, and AI Tools for Peak Productivity

Optimizing Your Flutter Dev Setup: IDEs, Simulators, and AI Tools for Peak Productivity

Flutter developers frequently seek to refine their development environments. This post will dive into popular IDE choices like VS Code and Android Studio, discuss best practices for managing iOS and Android simulators (including in-IDE options), and explore the practical integration of AI tools for code generation and problem-solving to boost overall efficiency.

Cover image for Demystifying Flutter Performance: Practical Strategies for Large-Scale Apps

Demystifying Flutter Performance: Practical Strategies for Large-Scale Apps

Flutter's performance is often blamed for issues in complex applications, but the real culprits are usually architectural decisions, inefficient widget rebuilds, and unoptimized resource handling. This post will dive into common performance bottlenecks in large Flutter apps, providing actionable strategies for profiling, optimizing state management, handling images and network requests efficiently, and leveraging CI/CD for continuous performance monitoring.