← 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 Flutter's Hidden Gem: Building Powerful Linux Desktop Apps with Ease

Flutter's Hidden Gem: Building Powerful Linux Desktop Apps with Ease

Flutter is gaining traction as a robust framework for Linux desktop development, offering a smoother experience compared to traditional toolkits like GTK and Qt. This post will explore why Flutter excels for Linux apps, highlight its advantages, and provide practical insights for developers looking to leverage Flutter for their desktop projects.

Cover image for Simplifying Flutter Desktop Deployment: Signing and Distribution for Windows

Simplifying Flutter Desktop Deployment: Signing and Distribution for Windows

Deploying Flutter desktop apps on Windows can be challenging, especially regarding code signing and preventing Windows from blocking installations. This post will guide developers through the process of signing their Flutter desktop applications, exploring alternatives to the Microsoft Store like creating executable installers, and covering the necessary steps to ensure a smooth user experience.

Cover image for Mastering Flutter Tooling: Streamlining SDK Management and Installation on Windows

Mastering Flutter Tooling: Streamlining SDK Management and Installation on Windows

Developers frequently voice frustrations about Flutter SDK version management and installation processes, particularly on Windows. This post will explore best practices for managing multiple Flutter versions using tools like FVM, discuss integrating Flutter with package managers like Winget for a smoother Windows setup, and offer strategies to ensure a consistent development environment across projects and teams.