In a Flutter view model, why hold screen state in one immutable class updated with copyWith, and what trap does a hand-written copyWith hit with nullable fields?
answer
- one immutable snapshot per screen
- replace, then notifyListeners
- copyWith keeps unspecified fields
- null means 'not passed' by hand
- freezed copyWith can set null
basics
~20 sAn immutable state class replaced through copyWith keeps every field consistent and lets the view read one snapshot. A hand-written copyWith using nullable parameters with ?? cannot set a field back to null; freezed's generated copyWith can.
solid answer
~40 sFlutter's guide calls UI state an immutable snapshot of everything a view needs, and suggests a dedicated class such as `EditorUiState` once a view model exposes more than a couple of values. The view model holds one private `_state`, replaces it with `_state = _state.copyWith(saving: true)`, and calls `notifyListeners()`; nothing outside can mutate half of it. The trap: a hand-written `copyWith({String? errorMessage})` that does `errorMessage: errorMessage ?? this.errorMessage` cannot clear the message, because passing `null` looks the same as not passing it. Fixes are a sentinel, a wrapper parameter, a dedicated `clearError()` method, or generating the class with freezed, whose `copyWith(errorMessage: null)` does set the field to null.
code
dart · 43 linesimport 'package:flutter/foundation.dart';
const Object _unset = Object();
@immutable
class EditorUiState {
const EditorUiState({
this.body = '',
this.lastSavedAt,
this.errorMessage,
});
final String body;
final DateTime? lastSavedAt;
final String? errorMessage;
// Sentinel defaults: passing null clears a field, omitting keeps it.
EditorUiState copyWith({
String? body,
Object? lastSavedAt = _unset,
Object? errorMessage = _unset,
}) {
return EditorUiState(
body: body ?? this.body,
lastSavedAt: identical(lastSavedAt, _unset)
? this.lastSavedAt
: lastSavedAt as DateTime?,
errorMessage: identical(errorMessage, _unset)
? this.errorMessage
: errorMessage as String?,
);
}
}
class EditorViewModel extends ChangeNotifier {
EditorUiState _state = const EditorUiState();
EditorUiState get state => _state;
void edit(String body) {
_state = _state.copyWith(body: body, errorMessage: null); // clears
notifyListeners();
}
}go deeper
Know that copyWith returns a new object with only the named fields changed.
Explain the nullable trap in a ?? based copyWith and at least two fixes, including freezed.
Design the state class with equality for tests, decide which flags live on commands versus state, and keep updates atomic.
Weigh generated state classes and their build cost against hand-written ones across many features.
## One snapshot instead of loose fields Flutter's architecture guide describes **UI state** as an immutable snapshot of the data a view needs, and notes that when a view model's exposed values grow, a class such as `HomeUiState` can represent them. Its recommendations rate **immutable data models** as strongly recommended: because an immutable object cannot change after creation, every change creates a new instance, which keeps changes in the view model and supports one-way data flow. For a blogging app's editor, the state might be: | Field | Meaning | |---|---| | `body` | the draft text | | `lastSavedAt` | when the draft last saved, or `null` | | `saving` | a save is in progress | | `errorMessage` | the last failure to show, or `null` | With loose mutable fields, a method might set `saving = false` and forget `errorMessage`, and a listener could observe the half-updated state. With one immutable class, the view model builds the next state in one expression and swaps it in. ## The update loop 1. Read the current `_state`. 2. Build the next one with `copyWith`, naming only the fields that change. 3. Assign it and call `notifyListeners()`. `copyWith` returns a **new** object; every field you do not pass keeps its old value. Widgets then read `viewModel.state.saving` and friends from a single consistent snapshot. ## The nullable-field trap A typical hand-written `copyWith` looks like this: ```dart EditorUiState copyWith({String? errorMessage, bool? saving}) => EditorUiState( errorMessage: errorMessage ?? this.errorMessage, saving: saving ?? this.saving, ); ``` It works until you need to **clear** `errorMessage`. `copyWith(errorMessage: null)` passes `null`, the `??` falls back to the old message, and the error never goes away. Dart gives the method no way to tell 'passed null' from 'not passed' when both arrive as `null`. Common fixes: - **Generate it.** freezed's generated `copyWith` distinguishes the two cases, so `state.copyWith(errorMessage: null)` sets the field to `null`. The freezed 4 syntax is `@freezed abstract class EditorUiState with _$EditorUiState { const factory EditorUiState({...}) = _EditorUiState; }`. - **A sentinel default.** Declare the parameter as `Object? errorMessage = _unset` and compare with `identical(errorMessage, _unset)`. - **A wrapper.** Accept `String? Function()? errorMessage`, so `copyWith(errorMessage: () => null)` means 'set to null'. - **A dedicated method.** `clearError()` builds a new state with `errorMessage: null` explicitly. ## Equality and rebuilds `ChangeNotifier` does not compare old and new state; every `notifyListeners()` triggers listeners. Value equality matters when state is compared, such as skipping a notification when nothing changed, or asserting in tests that the state equals an expected value. freezed generates `==` and `hashCode` for this; a hand-written class needs them written and kept in sync with every new field. ## Where copyWith state stops The state class is for the **view model's** snapshot. The per-action `running`, `error` and `completed` flags of the guide's `Command` objects stay on the commands, so the state class does not need a `saving` flag for each action when commands are used. Pick one home for each flag.
- Does replacing the state object make ListenableBuilder rebuild less often?No. `ChangeNotifier` notifies on every `notifyListeners()` call regardless of what changed. Immutable state makes updates atomic and easy to compare; reducing rebuilds needs finer listenables or selectors.
- What does freezed generate besides copyWith for a state class?Value equality (`==` and `hashCode`) and `toString`, plus JSON methods if you add `fromJson`. Since freezed 3 the factory-constructor style must be declared on an `abstract` or `sealed` class, and the old `when`/`map` helpers are gone in favour of Dart's own switch patterns.
saying these in an interview costs you the question
- copyWith(errorMessage: null) always clears the field in any implementation.
- copyWith mutates the existing object and returns it.
- Immutable state stops ChangeNotifier from notifying when nothing changed.
- freezed classes can still use the removed when and map helpers.
- Mutable public fields on the view model are just as safe.