@domainkit/react
The React namespaces, the flow hook and what it returns, the controllers and their states, the message catalog, and the read-only contract.
import { Domain, DomainKit } from "@domainkit/react";
The package entry carries "use client". It exports hooks and helpers and renders no element of its
own; the styled composition is the registry.
Namespaces
| Namespace | Carries |
|---|---|
DomainKit |
Root, ReadOnly, useDomainKit, useTransport, useMessages, useCapabilities, useReadOnly |
Domain |
useFlow, and the Flow and FlowState shapes it returns |
Connect |
useController, useRecovery, useZones, useAccounts, useDomainField, describeMethods, rejectedField, attempted, offering, holdsConnection, reconnect, hostProvider, providerOf, displayName, reusableConnections, answeredInPlace, placementOf, suggestionsFor, completionOf |
Provision |
useController, State, pendingPlan, planOf |
Cleanup |
The same set over a plan built from an apply receipt |
Verify |
useController, State, valuesOf, summary, Supplied, and the readiness shapes it reports |
Records |
standingOf, useCopy, copyText, toZoneFile, downloadZoneFile, identity, requirementsKey |
Outcome |
describe(error, catalog) and useDescribe(), which binds the catalog the root holds |
Messages |
Catalog, english, merge, outcome(error, catalog), and failure(error, catalog) |
Testing |
transport, re-exported from domainkit/testing for UI tests |
Top-level exports
| Export | Carries |
|---|---|
Event |
What a customer finished doing: Connected, Applied, Cleaned, Declined, Failed, Detached, Disconnected |
Listener |
The onEvent signature DomainKit.Root takes |
Failure |
The failed member every controller state shares, carrying the DomainKit.Error |
Root
/**
* One hook. `Domain.useFlow` connects a provider, plans the changes, takes the customer's approval
* or refusal, applies the plan, observes the records, and cleans them up against the receipt. It
* reports only the capability groups the transport declares, and your markup renders it.
*/
function DomainSetup() {
const flow = Domain.useFlow({ domain: "app.example.com", requirements });
const writes = flow.plan === null ? [] : Plan.writes(flow.plan);
return (
<section>
<p>{flow.state.connected ? `Connected to ${flow.state.provider}` : "Not connected"}</p>
{writes.length === 0 ? null : (
<button onClick={() => flow.provisioning.approve()} type="button">
Add {writes.length} records
</button>
)}
</section>
);
}
export function DomainSettings() {
return (
<DomainKit.Root transport={transport}>
<DomainSetup />
</DomainKit.Root>
);
}DomainKit.Root renders a context provider and no element.
| Prop | Effect |
|---|---|
transport |
The transport, by value; its identity survives re-renders |
revision |
Bump to re-inspect every mounted domain after a change the UI did not make |
messages |
Overrides on Messages.Catalog |
navigate |
Where an interactive provider flow sends the customer. Defaults to window.location |
onEvent |
A Listener for finished steps |
readOnly |
Report the state without the commands that change it |
The flow
Domain.useFlow({ domain, requirements, connect?, onApplied?, onCleaned?, returnTo?, readOnly?, verification? })
runs the four controllers together, attaches nothing the customer has not granted, and plans as soon
as the domain is attached with nothing applied. An apply observes the domain again, and an applied
domain plans again once an observation made after the apply reads a record back missing or wrong, so
records deleted at the provider are offered again.
| Field | Carries |
|---|---|
state |
FlowState, below |
connection |
The connect controller |
provisioning, cleanup |
The plan, approve, apply machine for adding and for removing |
verification |
The observer, with the readiness it last read |
plan |
The plan still awaiting its apply, which is what the rows report |
readiness |
The last observation, which is what the rows report after an apply |
capabilities |
The groups the transport declares, so nothing offers a step it lacks |
requirements, domain |
What the host asked for, and for which domain |
invitation |
What the flow was told to offer when discovery names no host |
Controllers
Each takes one options object and returns a controller whose state is a tagged union.
| Hook | Options | State tags |
|---|---|---|
Connect.useController |
{ domain, returnTo?, readOnly? } |
Loading, Disconnected, Connected, Reconnect, Submitting, Redirecting, SelectionRequired, Failure |
Connect.useZones |
{ provider? } |
Loading, Ready, Failure |
Connect.useAccounts |
{ returnTo?, readOnly? } |
Loading, Ready, Submitting, Redirecting, Failure |
Provision.useController |
{ domain, requirements, onApplied?, readOnly? } |
Idle, Planning, Planned, Approving, Applying, Applied, Rejecting, Rejected, Failure |
Cleanup.useController |
{ domain, receiptId?, onCleaned?, readOnly? } |
The same set |
Verify.useController |
{ domain, polling?, requirements?, supplied? } |
Idle, Observing, Observed, Failure |
/**
* 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>
);
}Callback recovery
Connect.useRecovery() returns Connect.Recovery: an outcome of
CallbackRecovery.Model | null and dismiss(). It reads the current URL through the
shared codec and renders no markup. Server rendering
and the initial hydration render return null; the browser supplies the hint after hydration.
/** Mount inside DomainKit.Root on the page an interactive connection returns to. */
export function ConnectionRecovery() {
const recovery = Connect.useRecovery();
const accounts = Connect.useAccounts();
const messages = DomainKit.useMessages();
const readOnly = DomainKit.useReadOnly();
const outcome = recovery.outcome;
if (outcome === null) return null;
const provider = accounts.providers.find((entry) => entry.id === outcome.provider);
const name = provider?.name ?? outcome.provider;
const words =
outcome.kind === "cancelled"
? messages.recoveryCancelled(name)
: outcome.kind === "restart"
? messages.recoveryRestart(name)
: messages.recoveryInspect(name);
const method =
provider === undefined ? undefined : Connect.describeMethods(provider).interactive[0];
const busy = accounts.state._tag === "Submitting" || accounts.state._tag === "Redirecting";
return (
<div role="alert">
<strong>{words.title}</strong>
<p>{words.description}</p>
{outcome.kind === "inspect" || method === undefined || readOnly ? null : (
<button
disabled={busy}
onClick={() => {
recovery.dismiss();
if (outcome.connectionId === undefined) {
accounts.connect({ provider: outcome.provider, method: method.kind });
} else {
accounts.reconnect({
connectionId: outcome.connectionId,
provider: outcome.provider,
method: method.kind,
});
}
}}
type="button"
>
{messages.retry}
</button>
)}
<button onClick={recovery.dismiss} type="button">
{messages.dismissRecovery}
</button>
</div>
);
}dismiss() removes all recovery parameters with history.replaceState, preserving the host’s
history state, unrelated query parameters, and fragment without navigation. Mounted readers update
together. Browser history and hash navigation update the reader; a host router that changes history
without emitting an event must rerender the consumer.
For cancelled and restart, start a fresh attempt through the existing controller’s
connect({ provider, method }). When outcome.connectionId is present, call
reconnect({ connectionId, provider, method }) instead so the existing account and its domains
keep their connection. Both Connect.useController and Connect.useAccounts expose this command;
Connect.ReconnectInput names the domain controller input. Dismiss removes the visible hint. Controllers also strip all recovery parameters when deriving
the default returnTo from the current URL, including when another control starts the attempt.
The current page and host-supplied return destinations are unchanged.
For inspect, check the account list through the authenticated transport before offering further
action: persistence can be partial. The hint neither retries nor replays a provider grant.
Messages.Catalog supplies recoveryCancelled(provider), recoveryRestart(provider), and
recoveryInspect(provider), each with a title and description, plus dismissRecovery and retry.
The domain flow and
domain field render these alerts with your kit.
The domain field
Connect.useDomainField({ zones, value, onChange, onResolve?, id? }) carries the combobox
semantics: inputProps, listboxProps, and optionProps(zone), the suggestions the typed value
reaches for, the highlighted one, the found placement, complete(zone), and close().
Connect.suggestionsFor offers the zones that already hold the typed name, matched on whole labels
and listed deepest first. Otherwise it offers a zone the value is still typing.
Connect.completionOf keeps the typed subdomain in front of the chosen zone, including when that
zone already holds the name.
/**
* One input over those zones. Arrow keys move the highlight, Tab or Enter finishes the name while
* keeping whatever subdomain was typed in front of the zone, and `found` says which account the
* records would go to.
*/
export function AddDomainField() {
const [value, setValue] = useState("");
const { zones } = Connect.useZones();
const field = Connect.useDomainField({ onChange: setValue, value, zones });
return (
<div>
<input {...field.inputProps} placeholder="app.example.com" />
<ul {...field.listboxProps}>
{field.suggestions.map((zone) => (
<li key={`${zone.connectionId}:${zone.zone}`} {...field.optionProps(zone)}>
{zone.zone}
</li>
))}
</ul>
<p>{field.found === null ? "Not in a connected account" : field.found.zone.label}</p>
</div>
);
}Flow state
| Field | Carries |
|---|---|
connection |
Connect.State["_tag"] |
provisioning |
Provision.State["_tag"] |
connected / offering |
Whether DomainKit holds a connection, and whether it is offering one |
provider |
Who holds the connection, or whose nameservers serve the zone |
label |
The account the records go to, from the attachment; null until there is one |
receiptId / applied |
The apply receipt this domain produced, and whether there is one |
readOnly |
The customer may read this domain but not write to it |
Read-only
readOnly refuses commands that change the domain: connect, reconnect, attach, detach, disconnect,
plan, approve, decline, apply, and provisioning or cleanup retry. The refusal is in the controller,
so rendering a control anyway cannot send those commands to the transport. Connection retry
re-inspects instead of repeating a write; verification observe and retry stay available.
The connection, account, provisioning, and cleanup controllers accept readOnly. The verification
controller has no readOnly option. Its supplied option takes host-owned readiness and an optional
observe callback, which both observe and retry call; without that callback, both do nothing.
The flow accepts the same value as verification; see
readiness the host holds.
| Export | Answers |
|---|---|
DomainKit.useReadOnly |
Whether this surface may offer a control that changes the domain |
DomainKit.ReadOnly |
Narrows one subtree, overriding the root for its children |
/** 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>;
}It is not capability gating. A group the transport never declares is absent from
flow.capabilities; readOnly covers the authorization a transport cannot express, such as a
member who reaches the same routes and would get a 403 from any write. Where a connect control would
be, the catalog’s administratorConnects(provider) sentence takes its place.
Words
Messages.Catalog holds every user-visible string, including a title and a description per
DomainKit.Error reason. Outcome.useDescribe() binds the merged catalog and returns that pair for
one error; Outcome.describe(error, catalog, context?) is the same answer without a React context.
/** 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>
);
}