Skip to main content

Modeling state

The State class is the contract between a hook and its View: every value the View renders and every action the user can trigger, in one place. How you shape that class decides how readable the View is, how the hook reads at its return statement, and how well automated refactors hold up. This page covers what goes in the class, how to represent it, and the trade-offs between the available forms.

What belongs in the class

A State holds four kinds of member, and nothing else:

  1. Data - every value the View needs to render (a loaded entity, a loading flag, a list).
  2. Actions - every user action, as void Function() fields the View calls.
  3. A constructor - taking all data and actions as required named parameters.
  4. Derived data - optionally, getters computed from the other fields.
// A plain immutable class: final fields, one constructor, nothing generated.
class ProductScreenState {
// Data - everything the View renders
final Product? product; // null = still loading
final bool isSaving;

// Actions - what the user can do; the View calls these
final void Function() onSavePressed;

const ProductScreenState({
required this.product,
required this.isSaving,
required this.onSavePressed,
});

// Derived data - computed from the fields above, not stored
bool get canSave => product != null && !isSaving;
}

It holds no BuildContext, no widgets, and no Flutter imports beyond Color. It also holds no cross-screen global state as a field - re-exporting a whole AuthState would rebuild the entire View subtree whenever any unrelated field of that global changes. Project the specific values the View needs instead (final bool isLoggedIn;). See Screen / State / View.

Actions are fields, not methods

Expose each action as a function-typed field. The hook builds the function; the View calls it. The View never decides what an action does - that logic lives in the hook, closed over the state it needs.

// Actions are fields, not methods on the View. The View stays declarative: it
// reads data and calls actions, never deciding what an action does.
class CartScreenState {
final int itemCount;
final void Function() onCheckoutPressed;
final void Function(String id) onRemovePressed;

const CartScreenState({
required this.itemCount,
required this.onCheckoutPressed,
required this.onRemovePressed,
});
}

Two consequences fall out of this:

  • Actions take parameters but return nothing. A value the View needs is a Data field, not an action's return value - including a Future to signal progress. Expose progress as a bool driven by useSubmitState instead of returning Future<void>.
  • Dumb two-way bindings can skip the callback. For a toggle or dropdown with no logic behind it, expose a MutableValue<T> directly - the View reads and writes .value. Reserve void Function() for actions that carry logic.
final MutableValue<bool> notificationsEnabled; // View: state.notificationsEnabled.value = it

Immutability

The Data fields are final. The View reads a snapshot; the next snapshot is a new State instance built by the next run of the hook. This is also why mutable collections do not belong in state - mutating a List in place does not produce a new value, so nothing rebuilds (see Don't mutate a useState collection). Model collection fields with an immutable type:

// DON'T - a mutable List invites in-place mutation that no one observes
final List<CartItem> items;

// DO - an immutable IList; updates produce a new value
final IList<CartItem> items;

IList, IMap, and ISet come from package:fast_immutable_collections, the project's default for collections in state.

Choosing a form: plain class, record, or freezed

The State can be a hand-written class, a Dart record, or a generated freezed class. They are not interchangeable - each trades differently against the things that matter for a State object.

Plain immutable class

The default. A final-field class with a single constructor - the form shown above. It needs no operator==, no hashCode, and no copyWith: the constructor is called in exactly one place, at the end of the hook, so required already guarantees every field is set, and the hook produces a fresh instance each build rather than copying an old one.

  • For: zero tooling, no build step, reads top-to-bottom, and - because it is hand-written - automated rename/extract refactors operate on it directly.
  • Against: the constructor is boilerplate you maintain by hand. For a large State that is a real cost.

Record

A record suits a small State, or a sub-hook's return value, where naming a class adds little:

// A sub-hook returning two values - a record is lighter than a named class
({bool isLoading, IList<Task> tasks}) useTaskListState() {
// ...
return (isLoading: loadingState.value, tasks: tasksState.value);
}
  • For: no declaration at all; structural equality for free.
  • Against: no derived getters, no named type to reference across files, and function-typed fields in a record type signature get noisy fast. Past a few fields, or once you want a bool get canSave, a class reads better.

Freezed

freezed generates ==, hashCode, copyWith, and union types from an annotated class.

  • For: when the State genuinely needs value equality or copyWith - and especially its sealed-union support, which models mutually exclusive states (loading / loaded / error) as distinct types the View can exhaustively switch over.
  • Against: it adds a build_runner step, and copyWith cuts against the grain of this architecture - the hook is meant to rebuild the State from its current hook values each pass, not patch the previous instance. For a screen State whose equality is never compared, the generation is overhead without payoff.
tip

Reach for freezed when you are modeling a domain type that needs equality or union semantics. For a screen's State object, the plain class is usually the right default - the hook rebuilds it every pass, so the generated == / copyWith rarely earn their build step.

See also