flutter
/

BLoC State copyWith: Mastering Immutable State Updates

Last Sync: Today

On this page

9
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

BLoC State copyWith: Mastering Immutable State Updates

What is copyWith in BLoC?

In BLoC, states are immutable objects. The copyWith method is a common pattern that allows you to create a new instance of a state class by copying existing values and replacing only the fields you want to change. This ensures immutability while providing a convenient way to update state without having to manually pass all fields. It's typically combined with Equatable to enable correct equality comparisons and avoid unnecessary widget rebuilds.

Why Use copyWith?

  • Immutability – Preserves the benefits of immutable state: predictable, easy to reason about, and safe for concurrency.
  • Clean Code – Reduces boilerplate when updating only a few fields.
  • Type Safety – Avoids mistakes like forgetting to copy a field.
  • Performance – Works seamlessly with Equatable to prevent unnecessary rebuilds.
  • Maintainability – Adding new fields to a state class requires minimal changes to update code.

Basic Implementation

Here’s a typical state class using Equatable with a custom copyWith method.

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

class UserState extends Equatable {
  final String name;
  final int age;
  final bool isLoading;

  const UserState({
    required this.name,
    required this.age,
    this.isLoading = false,
  });

  UserState copyWith({
    String? name,
    int? age,
    bool? isLoading,
  }) {
    return UserState(
      name: name ?? this.name,
      age: age ?? this.age,
      isLoading: isLoading ?? this.isLoading,
    );
  }

  @override
  List<Object?> get props => [name, age, isLoading];
}

Now you can update state immutably in your BLoC:

DARTRead-only
1
emit(state.copyWith(age: state.age + 1));
// or
emit(state.copyWith(name: 'Jane', isLoading: true));

Handling Nested Objects

When your state contains other objects, you need to ensure those objects are also copied immutably. Use copyWith on nested objects as well.

DARTRead-only
1
class Address extends Equatable {
  final String street;
  final String city;
  const Address({required this.street, required this.city});

  Address copyWith({String? street, String? city}) => Address(
    street: street ?? this.street,
    city: city ?? this.city,
  );

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

class UserState extends Equatable {
  final String name;
  final Address address;
  // ...

  UserState copyWith({String? name, Address? address}) {
    return UserState(
      name: name ?? this.name,
      address: address ?? this.address,
    );
  }
}

// Updating nested field:
emit(state.copyWith(
  address: state.address.copyWith(city: 'New York'),
));

Using copyWith with Collections

Lists, maps, and sets are also immutable. When updating, you should create new collections rather than mutating existing ones.

DARTRead-only
1
class TodoState extends Equatable {
  final List<Todo> todos;
  final bool isLoading;

  const TodoState({this.todos = const [], this.isLoading = false});

  TodoState copyWith({List<Todo>? todos, bool? isLoading}) {
    return TodoState(
      todos: todos ?? this.todos,
      isLoading: isLoading ?? this.isLoading,
    );
  }

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

// Adding an item:
emit(state.copyWith(todos: [...state.todos, newTodo]));
// Removing an item:
emit(state.copyWith(todos: state.todos.where((t) => t.id != id).toList()));

Alternative: Freezed for Boilerplate Reduction

If you find writing copyWith manually repetitive, consider using the freezed package. It generates copyWith, toString, and Equatable implementation automatically.

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

part 'user_state.freezed.dart';

@freezed
class UserState with _$UserState {
  const factory UserState({
    required String name,
    required int age,
    @Default(false) bool isLoading,
  }) = _UserState;
}

// Usage:
emit(state.copyWith(age: state.age + 1));

Best Practices

  • Always use copyWith – Avoid directly modifying fields; always create new instances.
  • Use nullable parameters – Make all copyWith parameters optional and use ?? to fall back to current values.
  • Combine with Equatable – Ensures that BlocBuilder only rebuilds when the state actually changes.
  • Freeze collections – Use List.unmodifiable or const [] to prevent accidental mutations.
  • Don't mutate nested objects in place – Always create a new instance of nested objects when updating them.
  • Use freezed for complex states – Saves time and reduces bugs.
  • Name your copyWith method consistently – Always call it copyWith for familiarity.

Common Mistakes

  • ❌ Mutating collections directly – state.todos.add(todo) doesn't trigger updates; use [...state.todos, todo].
  • ❌ Forgetting to include new fields in copyWith – When you add a field to a state, you must also add it to copyWith and props.
  • ❌ Not using Equatable – Without it, BlocBuilder may rebuild even when copyWith produces a new instance with the same data.
  • ❌ Calling copyWith inside build methods – This can cause infinite rebuilds; do it inside BLoCs or event handlers.
  • ❌ Using default parameters incorrectly – If a field should be set to null, you need a way to differentiate; consider using required with sentinel values.

Conclusion

The copyWith pattern is essential for working with immutable state in BLoC. It reduces boilerplate, maintains immutability, and pairs perfectly with Equatable for efficient UI updates. Whether you write it manually or generate it with Freezed, mastering copyWith will make your BLoC code cleaner and more maintainable.

Try it yourself

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

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: BlocProvider(
        create: (_) => UserBloc(),
        child: UserPage(),
      ),
    );
  }
}

// ---------- State ----------
class UserState extends Equatable {
  final String name;
  final int age;
  final bool isLoading;
  final String? error;

  const UserState({
    this.name = 'John',
    this.age = 25,
    this.isLoading = false,
    this.error,
  });

  UserState copyWith({
    String? name,
    int? age,
    bool? isLoading,
    String? error,
  }) {
    return UserState(
      name: name ?? this.name,
      age: age ?? this.age,
      isLoading: isLoading ?? this.isLoading,
      error: error ?? this.error,
    );
  }

  @override
  List<Object?> get props => [name, age, isLoading, error];
}

// ---------- Events ----------
abstract class UserEvent extends Equatable {
  const UserEvent();
  @override List<Object?> get props => [];
}

class UpdateName extends UserEvent {
  final String name;
  const UpdateName(this.name);
}

class IncrementAge extends UserEvent {}
class LoadData extends UserEvent {}

// ---------- BLoC ----------
class UserBloc extends Bloc<UserEvent, UserState> {
  UserBloc() : super(const UserState()) {
    on<UpdateName>((event, emit) {
      emit(state.copyWith(name: event.name));
    });
    on<IncrementAge>((event, emit) {
      emit(state.copyWith(age: state.age + 1));
    });
    on<LoadData>((event, emit) async {
      emit(state.copyWith(isLoading: true, error: null));
      await Future.delayed(Duration(seconds: 1));
      // Simulate success
      emit(state.copyWith(isLoading: false));
      // Simulate error (uncomment to test)
      // emit(state.copyWith(isLoading: false, error: 'Failed to load'));
    });
  }
}

// ---------- UI ----------
class UserPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('copyWith Demo')),
      body: BlocBuilder<UserBloc, UserState>(
        builder: (context, state) {
          if (state.isLoading) {
            return Center(child: CircularProgressIndicator());
          }
          if (state.error != null) {
            return Center(child: Text('Error: ${state.error}'));
          }
          return Padding(
            padding: const EdgeInsets.all(16.0),
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text('Name: ${state.name}', style: TextStyle(fontSize: 24)),
                Text('Age: ${state.age}', style: TextStyle(fontSize: 24)),
                SizedBox(height: 20),
                Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    ElevatedButton(
                      onPressed: () => context.read<UserBloc>().add(IncrementAge()),
                      child: Text('+1 Age'),
                    ),
                    SizedBox(width: 10),
                    ElevatedButton(
                      onPressed: () {
                        // Show dialog to change name
                        showDialog(
                          context: context,
                          builder: (_) => AlertDialog(
                            title: Text('Change Name'),
                            content: TextField(
                              onSubmitted: (value) {
                                context.read<UserBloc>().add(UpdateName(value));
                                Navigator.pop(context);
                              },
                            ),
                            actions: [
                              TextButton(
                                onPressed: () => Navigator.pop(context),
                                child: Text('Cancel'),
                              ),
                            ],
                          ),
                        );
                      },
                      child: Text('Change Name'),
                    ),
                    SizedBox(width: 10),
                    ElevatedButton(
                      onPressed: () => context.read<UserBloc>().add(LoadData()),
                      child: Text('Reload'),
                    ),
                  ],
                ),
              ],
            ),
          );
        },
      ),
    );
  }
}

Test Your Knowledge

Q1
of 4

What is the primary purpose of the copyWith pattern in BLoC?

A
To clone a bloc instance
B
To create immutable state updates with minimal boilerplate
C
To dispose of resources
D
To handle errors
Q2
of 4

How do you update a list in an immutable way using copyWith?

A
state.todos.add(todo)
B
state.copyWith(todos: [...state.todos, todo])
C
state.todos = [...state.todos, todo]
D
state.copyWith(todos: state.todos + [todo]) (both B and D are correct)
Q3
of 4

Which package can automatically generate copyWith methods?

A
bloc_generator
B
freezed
C
equatable
D
provider
Q4
of 4

What should you always use alongside copyWith to ensure correct equality?

A
Equatable
B
BlocBuilder
C
StreamController
D
EventTransformer

Frequently Asked Questions

Do I have to implement copyWith manually?

Not necessarily. You can use packages like freezed or dart_mappable to generate copyWith automatically. For simple states, manual implementation is straightforward.

What's the difference between copyWith and just creating a new instance?

copyWith is a convenience method that lets you only specify the fields you want to change, while a constructor requires you to pass all fields. copyWith reduces code duplication and is less error-prone when adding new fields.

Can I use copyWith with sealed unions (e.g., freezed unions)?

Yes, Freezed supports sealed unions and generates copyWith methods that work correctly across different union cases.

How do I reset a field to its default value using copyWith?

If a field is nullable, you can pass null to set it to null. For non-nullable fields, you may need a sentinel value or a separate reset method.

Does copyWith affect performance?

The overhead is negligible. The benefits of immutability and clear code far outweigh any micro-performance cost.

Previous

bloc state immutability

Next

bloc initial state

Related Content

Need help?

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