Skip to content
DomainKit
Esc
↑↓navigate↵open⌘Jpreview
On this page

@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>
  );
}

Last updated on

Was this page helpful?