skip to content

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?

level: middleimportance: should knowfreq 32%

answer

  1. one immutable snapshot per screen
  2. replace, then notifyListeners
  3. copyWith keeps unspecified fields
  4. null means 'not passed' by hand
  5. freezed copyWith can set null

basics

~20 s

An 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 s

Flutter'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 lines
dart
import '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

for a junior

Know that copyWith returns a new object with only the named fields changed.

for a middle

Explain the nullable trap in a ?? based copyWith and at least two fixes, including freezed.

for a senior

Design the state class with equality for tests, decide which flags live on commands versus state, and keep updates atomic.

for a principal

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.