---
title: Workflow UI (React)
description: A complete `gateway-react` workflow UI you can drop next to a workflow, with boot, run state, pushed live events, metrics, node output, approvals, and a stale-data-free render model.
---

This example is a minimal first-class React UI for a workflow that ships with the Gateway. It demonstrates every piece a real UI exercises: the `createGatewayReactRoot` bootstrap, reading `?runId=` and the boot config, live event subscription, node-output reads, a lifecycle action, and an approvals strip, all in one file. Drop it at `.smithers/ui/<your-workflow>.tsx`, declare it from the workflow with `<UI entry="../ui/<your-workflow>.tsx" />`, and open it with `bunx smithers-orchestrator ui`.

For the full conceptual walkthrough, see [Custom Workflow UIs](/guides/custom-workflow-ui).

## The bundle

```tsx
/** @jsxImportSource react */
import { useMemo } from "react";
import {
  createGatewayReactRoot,
  useGatewayActions,
  useGatewayApprovals,
  useGatewayNodeOutput,
  useGatewayRun,
  useGatewayRunEvents,
  useGatewayRuns,
} from "smithers-orchestrator/gateway-react";

function runIdFromUrl(): string | undefined {
  if (typeof location === "undefined") return undefined;
  return new URLSearchParams(location.search).get("runId") ?? undefined;
}

function statusClass(status: string | undefined): string {
  if (status === "running" || status === "continued") return "running";
  if (status === "finished" || status === "succeeded") return "finished";
  if (status === "failed" || status === "cancelled") return "failed";
  if (status === "waiting" || status === "paused" || status?.startsWith("waiting-")) return "waiting";
  return "";
}

// The RPC payload types are intentionally loose `Record<string, unknown>` so
// the wire stays cheap to extend; narrow at the consumer with the structural
// shapes you actually rely on. `.smithers/ui/grill-me.tsx` uses the same pattern.
type RunSummary = { runId: string; status?: string; workflowKey?: string };
type RunStateView = { state?: string };
type RunRecord = { status?: string; workflowKey?: string; runState?: RunStateView };
type NodeOutputView =
  | { status: "produced"; row: Record<string, unknown> }
  | { status: "pending" | "failed" };

function App() {
  const initialRunId = useMemo(runIdFromUrl, []);
  // `useGatewayRuns` lets the picker work when no `?runId=` was passed.
  // `ListRunsRequest.filter` accepts `status` and `limit` only; the bundle is
  // already mounted for one workflow, so there is no workflow filter on the wire.
  const runs = useGatewayRuns({ filter: { limit: 20 } });
  const runSummaries = (runs.data ?? []) as RunSummary[];
  const runId = initialRunId ?? runSummaries[0]?.runId;

  // Live run + event stream. Both clear automatically when `runId` changes,
  // so no previous run's data ever leaks into the new one.
  const run = useGatewayRun(runId);
  const runRecord = run.data as RunRecord | undefined;
  const status = runRecord?.runState?.state ?? runRecord?.status;
  const events = useGatewayRunEvents(runId, { maxEvents: 200 });
  const approvals = useGatewayApprovals(runId ? { filter: { runId, limit: 20 } } : {});

  // Read the final task's output row. Pending runs return `status: "pending"`.
  const shipOutput = useGatewayNodeOutput({ runId, nodeId: "ship" });
  const shipView = shipOutput.data as NodeOutputView | undefined;

  const { submitApproval, cancelRun } = useGatewayActions();

  return (
    <main className="shell">
      <header className="topbar">
        <h1>Demo React Workflow UI</h1>
        <span className={`status ${statusClass(status)}`}>
          {status ?? "-"}
        </span>
        <code data-testid="demo-run-id">{runId ?? "(no run)"}</code>
        <button onClick={() => runId && cancelRun({ runId })} disabled={!runId}>
          Cancel
        </button>
      </header>

      <section className="row">
        <div className="card">
          <h2>Ship output</h2>
          {shipOutput.loading && <em>loading…</em>}
          {shipOutput.error && <pre className="err">{shipOutput.error.message}</pre>}
          {shipView?.status === "produced" && (
            <pre>{JSON.stringify(shipView.row, null, 2)}</pre>
          )}
          {shipView?.status === "pending" && <em>not produced yet</em>}
        </div>

        <div className="card">
          <h2>Pending approvals</h2>
          {(approvals.data ?? []).map((a) => (
            <div key={`${a.runId}:${a.nodeId}:${a.iteration ?? 0}`} className="row gap">
              <code>{a.nodeId}</code>
              <button
                onClick={() =>
                  submitApproval({
                    runId: a.runId,
                    nodeId: a.nodeId,
                    iteration: a.iteration,
                    decision: { approved: true },
                  })
                }
              >
                Approve
              </button>
              <button
                onClick={() =>
                  submitApproval({
                    runId: a.runId,
                    nodeId: a.nodeId,
                    iteration: a.iteration,
                    decision: { approved: false },
                  })
                }
              >
                Deny
              </button>
            </div>
          ))}
          {!approvals.data?.length && <em>none waiting</em>}
        </div>
      </section>

      <section className="card events">
        <h2>Live events {events.streaming ? "•" : "○"}</h2>
        <ul>
          {events.events.slice(-20).map((frame) => (
            <li key={frame.seq}>
              <code>{String(frame.seq).padStart(4, "0")}</code> {frame.event}
            </li>
          ))}
        </ul>
        {events.error && <pre className="err">{events.error.message}</pre>}
      </section>
    </main>
  );
}

// Minimal styles so the demo reads as a polished surface, not an unstyled tree.
const styles = `
:root { color-scheme: dark; }
body { margin: 0; background: #0b0b0e; color: #eaeaee; font: 13px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; }
.shell { display: flex; flex-direction: column; gap: 16px; padding: 20px; max-width: 960px; margin: 0 auto; }
.topbar { display: flex; gap: 12px; align-items: center; }
h1 { font-size: 16px; font-weight: 600; margin: 0; flex: 1; }
.status { font-size: 11px; padding: 4px 10px; border-radius: 999px; background: #1a1a1f; border: 1px solid #2a2a30; text-transform: uppercase; letter-spacing: 0.04em; }
.status.running { color: #5e8df0; border-color: #2a3f6a; }
.status.finished { color: #4ade80; border-color: #2a5a3a; }
.status.failed { color: #f87171; border-color: #6a2a2a; }
.status.waiting { color: #fbbf24; border-color: #6a4a1a; }
code { font-family: ui-monospace, monospace; background: #1a1a1f; padding: 2px 8px; border-radius: 4px; font-size: 12px; }
button { background: #1a1a1f; color: #eaeaee; border: 1px solid #2a2a30; padding: 6px 12px; border-radius: 6px; font: inherit; cursor: pointer; }
button:hover:not(:disabled) { border-color: #5e6ad2; color: #fff; }
button:disabled { opacity: 0.45; cursor: not-allowed; }
.row { display: flex; gap: 12px; flex-wrap: wrap; }
.row.gap { gap: 8px; align-items: center; }
.card { background: #15151a; border: 1px solid #26262d; border-radius: 8px; padding: 16px; flex: 1 1 360px; }
.card h2 { font-size: 12px; font-weight: 500; color: #8a8a92; text-transform: uppercase; letter-spacing: 0.06em; margin: 0 0 12px; }
.card pre { margin: 0; font: 12px/1.5 ui-monospace, monospace; white-space: pre-wrap; word-break: break-word; color: #c5c5cc; }
.card .err { color: #f87171; }
.card.events ul { list-style: none; padding: 0; margin: 0; max-height: 200px; overflow-y: auto; font: 12px/1.6 ui-monospace, monospace; }
.card.events li { display: flex; gap: 8px; color: #c5c5cc; }
.card.events li code { background: transparent; padding: 0; color: #8a8a92; }
`;

if (typeof document !== "undefined") {
  const sheet = document.createElement("style");
  sheet.textContent = styles;
  document.head.appendChild(sheet);
}

createGatewayReactRoot(<App />);
```

## Declaring the UI

In your workflow file:

```tsx
/** @jsxImportSource smithers-orchestrator */
import { createSmithers, UI } from "smithers-orchestrator";
import { z } from "zod";

const { Workflow, Task, smithers, outputs } = createSmithers({
  result: z.object({ ok: z.boolean() }),
});

export default smithers(() => (
  <Workflow name="demo-react-ui">
    <UI entry="../ui/demo-react-ui.tsx" title="Demo React Workflow" props={{ brand: "demo" }} />
    <Task id="ship" output={outputs.result}>{{ ok: true }}</Task>
  </Workflow>
));
```

Then:

```bash
bunx smithers-orchestrator up demo-react-ui -d
bunx smithers-orchestrator ui                          # opens the bundle in your browser
```

The Gateway discovers the `<UI>` declaration when it registers the workflow with its `entryFile`. The bundle is served at `/workflows/demo-react-ui`. The HTML shell injects `__SMITHERS_GATEWAY_UI__` and `?runId=` before your script runs; `createGatewayReactRoot` picks both up automatically.

## Embedding it in `apps/smithers`

The PWA discovers workflows that advertise `hasUi: true` and renders this exact bundle in an iframe at `/gw/demo-react-ui/<runId>`. The same-origin Vite proxy (in dev) and Worker proxy (in Cloud) make the iframe's RPC and WebSocket reach the real Gateway. No CORS, no token shuttling. See [Same-origin proxy patterns](/guides/custom-workflow-ui#same-origin-proxy-patterns).

## What this example demonstrates

- **`createGatewayReactRoot` boot**: one call, reads the boot config, mounts the provider, renders the tree.
- **`?runId=` parsing** with a safe fallback to the most recent run via `useGatewayRuns`.
- **Live subscription** with `useGatewayRunEvents`: resilient reconnect, pushed updates, gap resync, metrics, and automatic teardown when `runId` changes.
- **Node output reads** with `useGatewayNodeOutput`: handles `produced` / `pending` / `failed`.
- **Approvals strip** with `useGatewayApprovals` + `useGatewayActions().submitApproval`.
- **Lifecycle action** with `useGatewayActions().cancelRun`.
- **Stale-data-free transitions**: every read and the event stream clears synchronously when `runId` changes, so a late response from the previous run can never repopulate state. See [Stale-data-free update model](/guides/custom-workflow-ui#stale-data-free-update-model).
