← 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 for High-Performance Desktop: Is it Ready for CAD, Image Processing, and Complex GUIs?

Flutter for High-Performance Desktop: Is it Ready for CAD, Image Processing, and Complex GUIs?

Developers are curious about Flutter's capabilities beyond typical business apps, especially for demanding desktop applications like CAD/CAM or image/video processing. This post will explore Flutter's suitability for high-performance, viewport-based desktop GUIs, discussing Dart's memory model, the 60fps update loop, and real-world examples to gauge its readiness for 'serious' complex software.

Cover image for Debugging Flutter Web Navigation: Solving the Deep Link Refresh Bug

Debugging Flutter Web Navigation: Solving the Deep Link Refresh Bug

Flutter web applications often suffer from a frustrating 'deep link refresh bug' where refreshing the browser on a nested route (e.g., /home/details) bounces the user back to the root or an incorrect path. This post will diagnose the common causes of this issue, explain how Flutter's router handles web URLs, and provide practical solutions and best practices for building robust, refresh-proof navigation in your Flutter web apps.

Cover image for Mastering Internationalization in Flutter: Centralized Strings for Scalable Apps

Mastering Internationalization in Flutter: Centralized Strings for Scalable Apps

As Flutter applications grow, managing strings for multiple languages or just keeping text consistent becomes a challenge. This post will guide developers through effective strategies for centralizing strings, implementing robust internationalization (i18n) and localization (l10n), and leveraging tools to streamline the process for small to large-scale projects.