ReactでComfyUIの生成状況を表示するには、状態をidle / queued / running / success / error / cancelledの6つに固定し、サーバーのイベントだけで進めます。進捗率はprogressイベントにvalue / maxがあるときだけ表示します。
情報確認日:2026年8月22日(日本時間)
結論:状態は6つ、イベントは1か所で受け、推測の数字は出さない
責任範囲
- 6状態と、それぞれで画面に出すもの
- 状態遷移図と
useReducerでの実装 - WebSocketをカスタムHookに閉じ込める形
- 進捗率を捏造しない表示
- Cancel・Retryの制限
- リロード後の復元
WebSocketのメッセージ仕様と再接続はWebSocketで進捗を取得する記事、job IDとキャンセル・再試行のサーバー側設計はQueue制御の記事、ブラウザとComfyUIの間にNext.jsを置く構成はNext.jsの記事で扱っています。この記事は、届いたイベントをReactの画面にどう反映するかだけを扱います。
生成UIに必要な状態
「ローディング中」のboolean 1つでは足りません。Queue待ちか、実行中か、失敗か、中断かで文言と押せるボタンが変わるため、先に6つの状態を名前で固定します。
| 状態 | 意味 | 画面に出すもの | 押せる操作 |
|---|---|---|---|
idle |
未送信、または結果を閉じた | 入力フォーム | Generate |
queued |
受理済みでQueue待ち | 「待機中」・statusのqueue_remainingがあれば残り件数 |
Cancel |
running |
ノード実行中 | 「生成中」・ノード名・progressがあればバー |
Cancel |
success |
完了 | 最終画像 | New / Retry |
error |
execution_errorまたは通信失敗 |
エラー要約 | Retry / New |
cancelled |
execution_interrupted |
「中断しました」 | Retry / New |
queuedとrunningを分けるのは、待ち時間の原因を伝えるためです。Queue待ちを「生成中」と出すと、ユーザーには自分の生成が遅いと見えます。
状態遷移を作る
どのイベントでどの状態に移るかを先に図にし、そのままreducerに写します。図にない遷移は起こさないのが要点です。
idle ──submit()──▶ queued ──executing(node≠null)──▶ running ──executing(node=null)──▶ success
│ │ ──execution_success──▶ success
│ ├──execution_error──▶ error
│ └──execution_interrupted──▶ cancelled
├──submit失敗──▶ error
└──cancel()──▶ cancelled
success / error / cancelled ──reset()──▶ idle
success / error / cancelled ──retry()──▶ queued(同じ入力で再投入)
// lib/generation-reducer.ts
export type Phase = "idle" | "queued" | "running" | "success" | "error" | "cancelled";
export type GenState = {
phase: Phase;
jobId: string | null;
currentNode: string | null;
progress: { value: number; max: number } | null; // 実データがあるときだけ
previewUrl: string | null;
images: string[];
error: string | null;
};
export type GenAction =
| { type: "SUBMITTED"; jobId: string }
| { type: "SUBMIT_FAILED"; message: string }
| { type: "EXECUTING"; node: string | null }
| { type: "PROGRESS"; value: number; max: number }
| { type: "PREVIEW"; url: string }
| { type: "SUCCESS"; images: string[] }
| { type: "ERROR"; message: string }
| { type: "INTERRUPTED" }
| { type: "RESET" };
export const initialState: GenState = {
phase: "idle", jobId: null, currentNode: null, progress: null, previewUrl: null, images: [], error: null,
};
export function reducer(state: GenState, action: GenAction): GenState {
switch (action.type) {
case "SUBMITTED":
return { ...initialState, phase: "queued", jobId: action.jobId };
case "SUBMIT_FAILED":
return { ...state, phase: "error", error: action.message };
case "EXECUTING":
if (action.node === null) return { ...state, phase: "success", currentNode: null, progress: null };
return { ...state, phase: "running", currentNode: action.node, progress: null };
case "PROGRESS":
return { ...state, phase: "running", progress: { value: action.value, max: action.max } };
case "PREVIEW":
return { ...state, previewUrl: action.url };
case "SUCCESS":
return { ...state, phase: "success", images: action.images, currentNode: null, progress: null };
case "ERROR":
return { ...state, phase: "error", error: action.message, progress: null };
case "INTERRUPTED":
return { ...state, phase: "cancelled", progress: null };
case "RESET":
return initialState;
}
}
reducerは純粋関数として新しいオブジェクトを返します(React公式リファレンスで確認)。状態が固定されているので矛盾した画面は作れません。公式仕様ではexecutingのnodeがnullのとき完了なのでsuccessへの遷移に使い、画像一覧はjob APIから取ってSUCCESSで差し込みます。
WebSocketをReactにつなぐ
接続・受信・切断はカスタムHookに閉じ込めます。React公式の「外部システムに接続する」パターンどおり、useEffect内で接続しクリーンアップで切断します。
// hooks/useGenerationEvents.ts
import { useEffect } from "react";
import type { GenAction } from "@/lib/generation-reducer";
// 接続先は環境に合わせる。ComfyUIに直結する開発時は ws://127.0.0.1:8188/ws?clientId=...、
// 本番はNext.jsなどの中継サーバー。ここでは「jobIdを渡すとそのjobのイベントだけ流す」中継を想定。
export function useGenerationEvents(jobId: string | null, dispatch: (a: GenAction) => void) {
useEffect(() => {
if (!jobId) return;
const ws = new WebSocket(`${process.env.NEXT_PUBLIC_EVENTS_WS_URL}?jobId=${jobId}`);
ws.onmessage = (ev) => {
if (typeof ev.data !== "string") return; // バイナリ(プレビュー)は別途扱う
const msg = JSON.parse(ev.data) as { type: string; data: any };
switch (msg.type) {
case "executing":
dispatch({ type: "EXECUTING", node: msg.data.node ?? null });
break;
case "progress":
dispatch({ type: "PROGRESS", value: msg.data.value, max: msg.data.max });
break;
case "execution_error":
dispatch({ type: "ERROR", message: "generation failed" });
break;
case "execution_interrupted":
dispatch({ type: "INTERRUPTED" });
break;
}
};
ws.onerror = () => dispatch({ type: "ERROR", message: "connection error" });
return () => ws.close(); // 依存が変わる・unmount時に必ず閉じる
}, [jobId, dispatch]);
}
開発モードではsetup → cleanup → setupが1回余分に実行されます(公式のStrict Modeの記述)。クリーンアップでclose()していれば接続は二重に残りません。フィールドの意味と再接続はWebSocket記事に任せます。
途中状態と最終画像
progressイベントは、対応するノード(典型的にはKSampler)がvalue / maxを送るときだけ届き、VAE Decodeやモデル読み込みの間は届きません。全体の進捗率は出せません。
推測の%を出さない:経過時間からの推定は長引くほど実態とずれ、信頼を下げます。データがあるときは「ノード名+value / max」、ないときは不確定表示と「生成中(現在: ノード名)」だけにします。
表示はphaseで分岐し、バーはstate.progressがあるときだけ<progress value max>、ないときはvalueを省いた不確定表示にします。途中プレビューは中継がある構成だけPREVIEWで差し込み、最終画像はsuccess後にjob APIのURL一覧を表示します。
CancelとRetryを状態で制限する
ボタンの活性は個別のフラグではなくphaseから導きます。
const canCancel = state.phase === "queued" || state.phase === "running";
const canRetry = state.phase === "success" || state.phase === "error" || state.phase === "cancelled";
const canSubmit = state.phase === "idle";
async function cancel() {
if (!canCancel || !state.jobId) return;
await fetch(`/api/jobs/${state.jobId}/cancel`, { method: "POST" });
// 状態は即座に変えない。サーバー経由で execution_interrupted が届いてから cancelled にする
}
押した瞬間にcancelledへ移さないのがポイントです。POST /interruptは実行中のjobを止め、待機中の削除は別操作で、成功はサーバーのイベントで確定します。「止まったように見えて実は生成されていた」を避けるため、状態はイベントで変えます。Retryは同じ入力で新しいjobを投入する操作です。
リロード後もjobを追跡する
状態はメモリ上なので、リロードでidleに戻ります。job IDをsessionStorageかlocalStorageに保存し、マウント時にサーバーへ現在の状態を問い合わせて復元します。
// 保存: SUBMITTED のとき
localStorage.setItem("comfy:lastJobId", jobId);
// 復元: マウント時
useEffect(() => {
const saved = localStorage.getItem("comfy:lastJobId");
if (!saved) return;
fetch(`/api/jobs/${saved}`)
.then((r) => r.json())
.then((job) => {
if (job.status === "done") dispatch({ type: "SUCCESS", images: job.images });
else if (job.status === "running" || job.status === "queued") dispatch({ type: "SUBMITTED", jobId: saved });
else localStorage.removeItem("comfy:lastJobId"); // 失敗・期限切れ・不明
});
}, [dispatch]);
正本はサーバーのjob状態です。SUBMITTEDでqueuedに戻せば、WebSocket Hookが再接続してその後のイベントを拾います。
再現してほしい確認:実イベントで状態遷移を記録する
reducerをラップして、実際の生成で状態がどう遷移したかを記録します。
// 開発時だけ: reducer をラップして遷移を記録
export function loggedReducer(state: GenState, action: GenAction) {
const next = reducer(state, action);
if (state.phase !== next.phase) {
console.debug(`[gen] ${state.phase} → ${next.phase} (${action.type}) ${new Date().toISOString()}`);
}
return next;
}
| No. | 操作・イベント | 期待する遷移 | 実際の遷移(ログ) | 時刻 | 画面の表示 |
|---|---|---|---|---|---|
| 1 | Generate を押す | idle → queued | |||
| 2 | executing(node≠null) |
queued → running | |||
| 3 | progress 受信 |
running のまま・バーが動く | |||
| 4 | executing(node=null) |
running → success | |||
| 5 | running 中に Cancel | running → cancelled(execution_interrupted 後) |
|||
| 6 | running 中にリロード | idle → queued(復元)→ success | |||
| 7 | ComfyUI を停止して Generate | idle → queued → error |
見るのは「図にない遷移がないか」と「Cancelからcancelledまでの時間」です。前者はreducerかイベント変換の漏れ、後者はサーバー側のキャンセル経路の問題です。
よくある質問
useReducer ではなく useState や状態管理ライブラリでも作れますか?
作れます。「状態を固定し、イベントでしか遷移しない」設計を満たすならZustandやXStateでも構いません。useReducerは追加依存なしでその形を強制しやすいため選んでいます。
複数のjobを同時に表示したい場合は?
Record<jobId, GenState>にし、イベントのprompt_id(サーバーでjobIdに変換)で振り分けます。1件分の遷移図は変わりません。
進捗率をどうしても出したいときは?
value / maxが届いているノードについてだけ出し、「そのノードの進捗で全体ではない」と注記してください。
まとめ
6状態とイベントだけで進む遷移図を先に決め、useReducerに写すと矛盾した画面が作れなくなります。WebSocketはHookに閉じ込め、進捗率はprogressがあるときだけ表示し、Cancel・Retryはphaseから活性を導きます。job IDを保存してサーバーから復元すれば、リロードしても生成を見失いません。