Controllers
The four hooks behind the flow, the tagged states they expose, the commands they take, and how an attempt is abandoned when its inputs change.
Use these hooks when you build the domain surface yourself. Each part of the flow is a hook that
takes one options object and returns a controller whose state is a tagged union, so a screen renders from state._tag and never from a boolean pair.
/**
* Compose the surface yourself from the same hooks `Domain.useFlow` runs. Each takes one options
* object and returns a controller whose `state` is a tagged union.
*/
export function DomainRow({ domain }: { readonly domain: string }) {
const connection = Connect.useController({ domain });
const provisioning = Provision.useController({ domain, requirements });
const cleanup = Cleanup.useController({ domain });
const verification = Verify.useController({ domain, polling: true });
if (!Connect.holdsConnection(connection)) {
return <ConnectButton controller={connection} />;
}
return (
<section>
<RecordRows readiness={verification.readiness} records={requirements} />
<ReviewActions controller={provisioning} />
<RemoveButton controller={cleanup} />
</section>
);
}Connection
Connect.useController({ domain, returnTo?, readOnly? })
| State | Means |
|---|---|
Loading |
Inspecting the domain |
Disconnected |
No connection. Carries discovery when one of the owner’s connections reaches the domain |
Connected |
Attached and usable |
Reconnect |
The credential can no longer be refreshed |
Submitting |
A connect, attach, detach, or disconnect is in flight |
Redirecting |
Sending the customer to the provider |
SelectionRequired |
The credential reaches several matching zones |
Failure |
Carries the DomainKit.Error |
| Command | Does |
|---|---|
connect({ provider, method, values?, returnTo? }) |
Starts a connection, or proves the same provider again for a connection that needs reconnecting |
reuse({ connectionId, zone? }) |
Attaches the domain to a connection the owner already has |
select(zone) |
Answers SelectionRequired |
detach() / disconnect() |
Releases the domain, or the whole connection |
refresh() / retry() |
Re-inspects, or re-runs the step that failed |
Discovery runs on mount whenever the transport declares it and the domain has no connection yet. A
discovery failure leaves discovery null and the provider list still renders: discovery is an
optimisation, not a step the customer asked for.
returnTo is where an interactive method sends the customer back to. It defaults to the page they
started from, read when they connect rather than when the controller renders, so a flow mounted on
one screen and used on another still returns to the right place. Pass null to send none and leave
the server’s defaultReturnTo in charge; a per-call connect({ returnTo }) still wins.
The controller attaches on sight: when it settles Disconnected with a Resolved discovery on a
surface that is not read-only, it calls reuse for that connection and zone straight away, so a
second domain on an account the workspace already holds never meets the connect dialog. Two
connections that both reach the zone stay a decision, and SelectionRequired keeps the prompt.
Each domain, connection, and zone is tried once, so an attach that failed and a detach the customer
chose both stand.
receipt carries what this domain’s last apply landed, read once per receipt id, so the connected
card can say how many records it holds without another call.
Zones and accounts
Connect.useZones({ provider? }) lists every zone the owner’s connections reach. Its state is
Loading, Ready { zones, connections, providers }, or Failure; a connection whose credential
the provider turned down appears in connections as reconnect and contributes no zones. Each
connection carries the account label its provider gave at connect time, so a list names accounts
without a provider call; it is null when the provider named no single account.
Connect.useAccounts({ returnTo?, readOnly? }) reads the same listing and adds two commands:
connect({ provider, method, values? }) starts a provider with no domain attached, and
reconnect({ connectionId, provider, method, values? }) replaces the credential behind a
connection the owner already holds, keeping its domains. A token method finishes in place; an
interactive one redirects and returns through the callback route your server mounts. The registry’s domain-field is a surface over both.
Provisioning and cleanup
Provision.useController({ domain, requirements, onApplied?, readOnly? }) and
Cleanup.useController({ domain, receiptId?, onCleaned?, readOnly? }) run the same machine.
| State | Means |
|---|---|
Idle |
Nothing planned yet |
Planning |
Building the plan |
Planned |
Carries the plan, waiting for the customer |
Approving |
Recording consent |
Applying |
Writing |
Applied |
Carries the receipt, complete or partial |
Rejecting |
Recording a refusal |
Rejected |
Carries the closed attempt |
Failure |
Carries the DomainKit.Error |
/**
* `approve` authorizes the digest and applies it in one action. `reject` records the refusal and
* is terminal. `retry` re-plans when the reason says the old plan is gone and re-runs the failed
* step otherwise.
*/
export function ReviewActions({ controller }: { readonly controller: Provision.Controller }) {
const state = controller.state;
if (state._tag !== "Planned") return null;
return (
<div>
<button onClick={() => controller.approve()} type="button">
Approve {state.plan.operations.length} change(s)
</button>
<button onClick={() => controller.reject("Not now")} type="button">
Decline
</button>
</div>
);
}approve(operationIds?) authorizes the digest and applies it in one customer action, because the
review screen offers one approve and one decline rather than approve and then apply. apply() uses
the approval this controller already holds; it does nothing until both a plan and an approval are
available. reject(reason?) is terminal: approving that plan afterwards fails Stale.
retry() builds a new plan when the reason says the old one is gone (Stale, Expired,
Conflict) and re-runs the failed step otherwise.
Verification
Verify.useController({ domain, polling?, requirements?, supplied? }) observes on mount when
supplied is absent, then re-observes at the readiness row’s own nextCheckAt while polling is
enabled and it stays mounted. readiness holds the latest observation even while a new one runs,
so the table does not blink. With supplied readiness, it observes nothing on mount and sets no timer.
requirements says what to look for. Without it the server uses the attachment’s latest
provisioning receipt, so a domain with nothing attached has nothing to observe; with it, that domain
still reports which records are in place. The set identifies itself by content, so writing the array
inline does not send the mount effect observing in a loop.
/**
* Observation stays available, because checking DNS reads the world rather than changing the
* domain, and it does not wait for a connection: the controller observes the requirements it was
* given, so a domain with no provider attached still reports which records are in place.
*/
export function UnattachedStatus({ domain }: { readonly domain: string }) {
const controller = Verify.useController({ domain, requirements });
return <p>{controller.readiness?.overall ?? "checking"}</p>;
}A requirement that is not satisfied renders what it expected, what each observer found for that
name, and the observer’s own detail line. An observer whose status is unknown never answered, so
it reports no values, and host evidence carries none either.
Abandoning an attempt
A controller’s plan, approval, and receipt only mean something for the inputs that produced them, so
changing domain or requirements drops them. The identity is the content of the requirement
records, not array identity, so writing requirements={[...]} inline does not lose a plan the
customer is reading.
Every command carries the key it was raised for and refuses once that key has moved, so a command raised from a layout effect in the same commit cannot act on the previous domain.
Failures
/** A failure keeps the `DomainKit.Error`, so read `reason`, `category`, and `isRetryable`. */
export function ConnectionProblem({ domain }: { readonly domain: string }) {
const describe = Outcome.useDescribe();
const connection = Connect.useController({ domain });
const state = connection.state;
if (state._tag !== "Failure") return null;
const words = describe(state.error, {
...(state.attempt === null
? {}
: { provider: Connect.displayName(connection, state.attempt.provider) }),
});
return (
<div role="alert">
<strong>{words.title}</strong>
<p>{words.description}</p>
{state.error.isRetryable ? (
<button onClick={connection.retry} type="button">
Try again
</button>
) : null}
</div>
);
}The failed state carries the DomainKit.Error itself, so a host reads reason, category, and
isRetryable instead of parsing text. Outcome.useDescribe() binds the catalog the root holds and
returns the title and the description for one error; Messages.failure joins them into one
sentence.
Capabilities and permission
/** Read what the server actually mounted before offering a step it cannot serve. */
export function RemoveButton({ controller }: { readonly controller: Cleanup.Controller }) {
const capabilities = DomainKit.useCapabilities();
if (!capabilities.includes("cleanup")) return null;
return (
<button onClick={controller.plan} type="button">
Remove records
</button>
);
}Capabilities say what the server can serve. readOnly says what this customer may do with it, which
no transport can express. A surface of your own reads it through DomainKit.useReadOnly(), and
DomainKit.ReadOnly narrows one subtree without touching the rest of the page.
/** A surface of your own asks which mode it is in. */
export function RemoveDomainButton({ onRemove }: { readonly onRemove: () => void }) {
const readOnly = DomainKit.useReadOnly();
if (readOnly) return null;
return (
<button onClick={onRemove} type="button">
Remove this domain
</button>
);
}
/** Or narrow one subtree without touching the rest of the page. */
export function ReadOnlySection({ children }: { readonly children: React.ReactNode }) {
return <DomainKit.ReadOnly value={true}>{children}</DomainKit.ReadOnly>;
}Read-only refuses commands that change the domain, including provisioning and cleanup retry.
Connection retry re-inspects instead of repeating a write. Verification observe and retry
stay available. With supplied readiness, both call the host’s optional observe callback, or do
nothing when it is absent.