A Request Lifecycle
An operator submits a support ticket. One field is invalid, but the form does not say which until the request comes back; by then the button has re-enabled and a second click has already sent the same form. The screen shows two results for one submission.
This recipe builds one feature from four modules. Each step is a separate concern, and each module owns exactly one of them:
flowchart TD Form["Support ticket form"] --> Validate["Validation.struct"] Validate -->|Failed| FieldErrors["Per-field errors"] Validate -->|Passed| Manager["Op manager: exclusive + retry + timeout"] Manager -->|OpOk| Success["RemoteData.Success"] Manager -->|OpErr| Failure["RemoteData.Failure"] Manager -->|OpNil| Idle["RemoteData.NotAsked"]
Validate at the boundary
Section titled “Validate at the boundary”The form has three fields and one cross-field rule. Validation.struct runs every check and returns
all failures at once, so the user fixes the whole form in one pass instead of one field per submit.
Validation stops nothing: every validator runs, and the caller receives the complete list. The
Validation guide covers per-field error grouping with Validation.keyed.
Run it under a policy
Section titled “Run it under a policy”Submitting is a request that can overlap with itself. An Op manager settles the previous call when
a new one arrives, and applies retry and timeout rules without the handler knowing about them.
strategy: "exclusive" drops a second submit while the first is in flight, which is the correct
behaviour for a button. The Op guide covers the other strategies and what OpNil
means for a dropped or replaced call.
Render the state it produces
Section titled “Render the state it produces”The manager already tracks its own lifecycle. Deriving the screen from manager.state keeps one
source of truth, and RemoteData.from.OpState performs the mapping without a hand-written match:
Field errors and request errors stay separate. The form owns validation; the manager owns the
request. A settled OpNil maps to NotAsked because there is no value and no error to show, and
the next run moves the state again.
Failure behavior
Section titled “Failure behavior”A validation failure never reaches the network. A rejected request settles the manager with OpErr
and the error produced by Task.Result.tryCatch. A timeout is an OpErr from onTimeout, not a
cancellation: the deadline covers the whole retry sequence. Replacement and dropping are OpNil
with a reason, and they are not request failures. If the caller aborts, the signal reaches the
fetch call and the manager settles with Aborted.
If the API can stay down for a while, wrap the same task in a
CircuitBreaker before the manager so repeated failures fail fast
instead of retrying into a dead service.
If the API enforces a quota, wrap the task in a RateLimiter instead. Put
the limiter inside the retry loop, so every attempt takes its own permit, and keep the retry
predicate aware of the refusal: a call that settles with RateLimitExceeded carries retryAfterMs,
which is the hint the screen needs to say when the submit can be tried again.