@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, 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>
);
}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().
/**
* 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, 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>
);
}