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

Last updated on

Was this page helpful?