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:
Modelis the complete immutable snapshot of business data for a feature, together with its available operations;Presentertransforms that snapshot into presentation-readyViewState;Viewrebuilds repeatedly fromViewStateand passes user intentions back to the Presenter;ViewEffectis 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:
- Is this a reproducible snapshot, a diagnostic result, or a UI command?
- What counts as handling it?
- Is loss acceptable when no screen is active?
- 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