copyWith for Immutable State Updates
4 min read
State Management
copyWith is a Dart convention for cloning an immutable object with one or two fields swapped out. The implementation is boring on purpose: every field becomes an optional named parameter, and if the caller doesn't pass one, it falls back to this.field via ??. Call state.copyWith(isLoading: true) and you get a new instance where isLoading is true and every other field is carried over unchanged.
That pattern lines up almost too neatly with modern state management. In BLoC, Riverpod and Provider, state classes are usually immutable (all final fields), so you can't mutate a property to change something, you have to emit a new instance. copyWith gives you the "change one field, keep the rest" ergonomics without writing out the full constructor each time. Pair the class with value equality (Equatable or freezed) and copies with identical contents compare equal, which lets the framework skip rebuilds when nothing meaningful changed.
Hand-written example
class CartState {
final List<Item> items;
final bool isLoading;
final String? error; // nullable, see the sentinel note below
const CartState({
this.items = const [],
this.isLoading = false,
this.error,
});
// Sentinel so callers can pass null to actually clear `error`.
static const _unset = Object();
CartState copyWith({
List<Item>? items,
bool? isLoading,
Object? error = _unset,
}) {
return CartState(
items: items ?? this.items,
isLoading: isLoading ?? this.isLoading,
error: identical(error, _unset) ? this.error : error as String?,
);
}
}
// Inside a Cubit, every "mutation" is a fresh emit.
class CartCubit extends Cubit<CartState> {
CartCubit() : super(const CartState());
void add(Item i) => emit(state.copyWith(items: [...state.items, i]));
void setLoading(bool v) => emit(state.copyWith(isLoading: v));
void fail(String msg) => emit(state.copyWith(error: msg));
void clearError() => emit(state.copyWith(error: null)); // works because of the sentinel
}
When to write it by hand vs generate it
| Approach | Good fit |
|---|---|
Hand-written copyWith | Small classes with 3 or 4 fields, no nullable field you need to clear |
Equatable + hand-written copyWith | You want value equality without pulling in build_runner |
freezed | 5+ fields, nullables you need to clear, sealed unions, JSON serialisation |
built_value | Legacy codebases. On new work reach for freezed instead |
Once a class has more than a handful of fields, hand-written copyWith becomes a place bugs hide (miss a field, and it silently resets on every copy). freezed also gives you ==, hashCode and toString in the same pass, so it usually pays for itself pretty quickly.
Common mistakes
// ❌ Naive impl can't clear a nullable field
CartState copyWith({String? error}) =>
CartState(error: error ?? this.error, ...);
// copyWith(error: null) silently keeps the old error.
// ✅ Use a sentinel Object, a Value<T> wrapper, or freezed.
// ❌ Mutating the existing collection instead of making a new one
state.copyWith(items: state.items..add(x));
// Same list reference, so value equality sees no change and listeners don't rebuild.
// ✅ state.copyWith(items: [...state.items, x])
// ❌ Adding a new field to the class and forgetting to add it to copyWith
// Silent bug: the field resets to its default on every copy.
// ✅ Use freezed. The generator can't forget.
// ❌ Chaining copyWith calls to fake a reducer
state.copyWith(a: 1).copyWith(b: 2).copyWith(c: 3);
// Three allocations for no reason. Do it in one call.
// ❌ Nested copyWith on deep object graphs
state.copyWith(user: state.user.copyWith(address: state.user.address.copyWith(...)));
// Painful and hard to read. Flatten the state, or split into sub-cubits.
Interview follow-ups
-
How does
copyWithinteract with==andhashCode? On its own, not at all.copyWithjust builds a new instance, so unless the class overrides equality two copies with identical fields still compare unequal. In a state class that's usually a bug: every emit looks like a change, every listener rebuilds, and you start chasing "why does this widget rebuild on every keystroke". The fix is to add value equality withEquatableorfreezedso identical copies short-circuit the notification. -
How do you let
copyWithset a nullable field tonull? The?? this.fieldfallback can't tell "not passed" from "passed as null", socopyWith(error: null)ends up doing the same thing ascopyWith(). Two ways out without code generation: a sentinelObjectused as the default value (like the example above), or a smallValue<T>wrapper that boxes "unset" vs "set to X". If you're already onfreezed, it handles this for you andcopyWith(error: null)just works.
How helpful was this content?
Please sign in to rate this article.