ATL-2026-003architectureexploratory

Error, state, and effect: three different contracts in a Flutter application

Separate an operation result, reproducible screen state, and a presentation effect; then choose delivery semantics before a rebuild or a new subscription chooses them by accident.

Published
August 28, 2026
Verified
August 22, 2026

Error, state, and effect: three different contracts in a Flutter application

A user saves a profile. The button becomes disabled, the request completes, the data on the screen is updated, and a “Profile saved” message appears at the bottom. Nothing about this ordinary scenario seems to suggest an architectural problem.

Then the app moves to the background. When it returns to the foreground, Flutter rebuilds part of the widget tree, and the same message appears again. The request did not run twice. The profile had already been saved. Only the UI action was repeated.

The apparent fix is to add a successMessageShown flag, set it after displaying the message, and reset it before the next save. That may work for one screen. Soon, however, the surrounding logic must also account for a route that has been closed, application restoration, two nearly simultaneous operations, and the exact moment at which a snackbar counts as shown: before the UI API is called, after the call returns, or after the snackbar actually disappears.

One boolean has quietly acquired a lifecycle of its own.

The problem is not the snackbar or any particular state-management package. It starts earlier, when data with different meanings is placed in one model simply because the UI can conveniently listen to it.

Three different contracts

At least three kinds of information exist while the profile is being saved.

The first is the operation result. The save either succeeds or ends with a specific failure. The caller needs that result in order to decide what happens next. The result does not need to know whether a failure will become a line of text, an invalid-field marker, or a diagnostic record with no visible UI at all.

The second is the screen state. The button is disabled, a progress indicator is visible, and the fields contain current values. State can be read repeatedly: if the screen rebuilds ten times, each build should still describe the same current UI state. This follows from Flutter's declarative model, in which the UI is a function of state (Common architecture concepts).

The third is the presentation effect. In this article, the term means an action requested of the UI after something has already happened: show a snackbar, open a dialog, restore focus, or navigate to another screen. An effect does not describe what the screen is now. It requires a delivery rule and a defined point after which it is considered handled.

This is not a difference in vocabulary. The contracts have different lifetimes.

Contract Question it answers Can be read repeatedly Who defines its meaning
Operation result How did the operation finish? Yes the operation itself
Screen state What should be visible now? Yes presentation layer
Presentation effect What action should the UI perform? Only under an explicit policy presentation boundary

If all three roles are represented by one ProfileSaved value, the UI has to infer its meaning from circumstances. A normal state transition may appear to work. A new subscription, screen restoration, or another read of the current value can turn the stored fact back into a command to display something.

A fact and a command are not the same thing.

Patterns in Flutter's official guidance

Flutter's official documentation addresses part of this problem with two patterns. Result represents the completion of an operation as explicit success or failure instead of relying on exceptions that are not visible in the caller-facing API (Result pattern). Command wraps one operation and exposes whether it is running, together with its result and error (Command pattern).

The documentation presents these patterns in an MVVM context—Model–View–ViewModel. In that architecture, the ViewModel sits between the UI and application data: it maintains the state required by the UI and exposes commands for user actions. The distinction between an operation result, screen state, and a presentation action does not depend on MVVM. Another architecture may assign the same responsibilities to a component with a different name.

One detail of the documented Command implementation is particularly important: after a listener consumes a result, the listener is expected to call clearResult. The documentation also warns that an uncleared error can trigger the same UI action again on a later notifyListeners() call.

That is already a consumption contract. A simple one, but a real one.

It still has a boundary that no method name can settle. When exactly is a result consumed? What happens if no screen is active? Should a newly opened screen receive an old action? Can more than one subscriber handle the same event? The answers depend on the action's delivery requirements and the cost of losing it, not on the selected state manager.

Clear before display or after it

Even a straightforward clearResult implementation requires a choice about timing.

Clear the result before showing the snackbar, and the action will not repeat. It will also count as handled before the UI has accepted it. Losing a “Profile saved” confirmation is usually an acceptable consequence.

Clear it after performing the UI action, and a different failure window opens: the screen may close between display and clearing. The next recipient can then see the result as unhandled. A repeat may be acceptable for some notifications. It is not necessarily acceptable for navigation or an external confirmation flow.

“Show once” is therefore not a sufficient requirement. The system needs a delivery policy:

  • deliver only to an active recipient, or wait for the next one;
  • discard the event when no screen is present, or retain it;
  • clear it before the action, after the action, or after separate acknowledgement;
  • allow redelivery or prohibit it;
  • limit the event's lifetime.

A field can be named oneShotEvent. The name does not make it one-shot.

Delivery to an active recipient only

The first policy suits actions that can be lost at little cost: showing a save confirmation, starting a local animation, or briefly highlighting an updated item.

Its contract is strict. Only a UI subscribed at the moment of emission receives the effect. If no recipient is present, the effect is discarded. No history is stored; a new subscription restores nothing, and no acknowledgement is required.

A separate event stream is enough to implement this policy. A Stream, however, is only a transport mechanism; it is not the source of the semantics. The meaning comes from the statements “active recipient only” and “loss is acceptable.” Without them, the same stream may be mistaken for a queue, a journal, or a form of guaranteed delivery.

The advantage is a small state surface and no stale actions after reopening the screen. The disadvantage follows directly from the contract: an event is lost if the screen is absent at the moment of emission.

That is not an implementation defect. It is the selected policy.

Acknowledged queue

The second policy is needed when silent loss is unacceptable. Each effect receives an identifier, a creation time, and a payload, and remains stored until explicit acknowledgement. A recipient reads the first queue entry, performs the action, and acknowledges it so that it can be removed.

sealed class ProfileEffect {
  const ProfileEffect();
}

final class ShowSavedNotice extends ProfileEffect {
  const ShowSavedNotice();
}

final class PendingEffect<E> {
  const PendingEffect({
    required this.id,
    required this.createdAt,
    required this.value,
  });

  final String id;
  final DateTime createdAt;
  final E value;
}

The code defines the record, but not the policy. Several decisions are still required:

  • whether the queue exists only in memory or survives process restart;
  • whether any subscriber may acknowledge an action or only a particular screen;
  • what happens to expired effects;
  • whether order is preserved when several actions are pending;
  • whether redelivery is allowed before acknowledgement.

A queue is not more complex because it needs more classes. It moves ownership of storage and delivery acknowledgement into the application. If the product does not need that responsibility, the queue merely creates another source of failure.

An error is not a message

The same kind of conflation occurs when an unsuccessful result immediately becomes a string:

errorMessage = 'Could not save the profile';

The UI may eventually need that string. At this point, however, the application has already lost what happened: invalid input, no network connection, a version conflict, a server refusal, or a defect in the application itself. It can no longer justify a retry, choose a different form state, or record the specific cause.

The presentation has been chosen before the failure has even been classified.

Dart distinguishes an Exception, which a caller should be able to handle programmatically, from an Error, which represents a program failure that normally should not become a user-facing branch (Exception, Error). An application may define a more precise failure model, but the principle remains the same: retain the diagnostic meaning until the boundary where its presentation is decided.

Only at that boundary does a result become text, a dialog, an invalid-field marker, a retry prompt, or no visible response at all.

The minimum architectural boundary

Before choosing a package, three types are enough to make the lifetimes explicit:

sealed class SaveProfileOutcome {
  const SaveProfileOutcome();
}

final class ProfileSaved extends SaveProfileOutcome {
  const ProfileSaved(this.profile);
  final Profile profile;
}

final class ProfileRejected extends SaveProfileOutcome {
  const ProfileRejected(this.issue);
  final SaveProfileIssue issue;
}

final class ProfileViewState {
  ProfileViewState({
    required this.saving,
    required this.profile,
    required List<SaveProfileIssue> issues,
  }) : issues = List<SaveProfileIssue>.unmodifiable(issues);

  final bool saving;
  final Profile? profile;
  final List<SaveProfileIssue> issues;
}

SaveProfileOutcome captures the terminal outcome while preserving its application-level meaning. ProfileViewState can reconstruct the screen. ProfileEffect from the previous example asks the presentation boundary to perform an action under a policy selected in advance.

The number of abstractions is secondary. What matters is whether the code makes it clear which component owns each lifetime, and which facts may be read repeatedly.

What this boundary looks like in an API

So far, these contracts have not depended on a package. A project can establish them through its own conventions, an adapter over an existing state manager, or separate types and channels. Some tools also make the boundary explicit in their API.

One example is Ark MVP: the ark_mvp 1.0.0 package and its Flutter integration, ark_mvp_flutter 1.0.0. The source used for this article is pinned in GitLab: ark_mvp, commit 2567bc4 and ark_mvp_flutter, commit 7a10248. The packages use Model–View–Presenter as a transformation boundary rather than merely as names for three classes:

  • Model is the complete immutable snapshot of business data for a feature, together with its available operations;
  • Presenter transforms that snapshot into presentation-ready ViewState;
  • View rebuilds repeatedly from ViewState and passes user intentions back to the Presenter;
  • ViewEffect is delivered over a separate channel to the active View only.

Ark MVP does not define the result type of a business operation. That contract belongs to the application. SaveProfileOutcome, a command state, or another result type becomes part of Model alongside the rest of the business data. The Presenter decides which part of the result belongs in ViewState and which part requires a separate ViewEffect.

A validation failure, for example, remains in ProfileViewState so the screen can render it after any rebuild. A successful save may produce ShowSavedNotice when the transition occurs during the current Presenter lifecycle. The effect must not be emitted from buildViewState: Flutter may invoke that transformation again because of a Model update, a theme or locale change, or an ordinary widget-tree rebuild.

In this example, ProfileModel is the complete business snapshot containing the current profile, save progress, result, and the save operation. ProfilePresenter transforms it into the already introduced ProfileViewState and emits ProfileEffect on a meaningful transition. profileBinding tells the MvpView runtime that the source may have changed; the runtime then reads a new complete ProfileModel and updates the Presenter.

In Flutter, MvpView wires these types together:

MvpView<
  ProfileModel,
  ProfileViewState,
  ProfileEffect,
  ProfilePresenter
>(
  model: profileBinding,
  createPresenter: ProfilePresenter.new,
  onEffect: (context, effect) {
    switch (effect) {
      case ShowSavedNotice():
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('Profile saved')),
        );
    }
  },
  builder: (context, viewState, presenter) {
    return ProfileView(
      state: viewState,
      onSave: presenter.save,
    );
  },
)

ProfileView is an ordinary Widget. It receives only presentation-ready state and the permitted save action; the business snapshot is not passed into it.

MvpView creates one Presenter per ModelBinding identity, subscribes to effects before starting the Presenter, and delivers each effect to onEffect with the current BuildContext. An ordinary rebuild retains the Presenter and does not replay an earlier event. If a Presenter emits an effect while onEffect is absent, Ark MVP Flutter reports MvpEffectListenerMissingException through onError or Flutter's error reporting rather than silently discarding the effect and hiding the contract violation.

This implements the active-view policy described earlier. The effect channel has no replay buffer, a new View does not receive earlier navigation requests or snackbar effects, and a closing screen has no delivery guarantee. Important information therefore cannot exist only as ViewEffect: it remains in business state and then in ViewState.

Ark MVP does not provide an acknowledged queue. If an action must wait for a future screen, survive process restart, or remain stored until separate acknowledgement, that contract belongs above the MVP boundary and requires a dedicated owner. Hiding it inside ViewEffect would combine two different delivery policies under one name.

What to inspect in a project

A useful review usually starts with show* flags, errorMessage strings, navigation inside business operations, and handlers that clear a result as soon as it is read. Four questions apply to each case:

  1. Is this a reproducible snapshot, a diagnostic result, or a UI command?
  2. What counts as handling it?
  3. Is loss acceptable when no screen is active?
  4. What should happen after a new subscription?

If an answer changes because of an incidental rebuild, the contract is still implicit.

This article does not establish that a queue is better than a stream, or that every snackbar needs an identifier. Its conclusion is narrower: state, result, and effect must not be combined merely because one subscription makes the UI easier to update.

The next useful step is a separate Flutter experiment with two minimal implementations and the same scenarios: active screen, closed route, new subscription, and two consecutive effects. Until that comparison exists, the choice between active-only delivery and an acknowledged queue remains an architectural decision for a particular context, not a universal ArkTelos recommendation.

Result boundaries

  • The article does not establish that a queue is better than a stream or that every presentation effect requires an identifier.
  • Active-only delivery and an acknowledged queue have not yet been compared in the planned Flutter experiment with closed-route, resubscription, and sequential-effect scenarios.

Telegram edition

Read in ArkTelos Lab

Read in ArkTelos Lab ↗

ARKTELOS CHANNELS

News and engineering material, without mixing languages.

The main channels cover ArkTelos development. ArkTelos Lab publishes architecture analysis, experiments, and reproducible research.