AI活用

ReactでComfyUIの生成状況を表示する|状態管理とプレビューの作り方

ReactでComfyUIの生成状況を表示する設計を解説。idle/queued/running/success/error/cancelledの6状態とuseReducerの遷移、WebSocketをHookに閉じ込める形、進捗率を捏造しない表示、Cancel・Retry、リロード後の復元まで分かります。

この記事の目次
  1. 結論:状態は6つ、イベントは1か所で受け、推測の数字は出さない
  2. 生成UIに必要な状態
  3. 状態遷移を作る
  4. WebSocketをReactにつなぐ
  5. 途中状態と最終画像
  6. CancelとRetryを状態で制限する
  7. リロード後もjobを追跡する
  8. 再現してほしい確認:実イベントで状態遷移を記録する
  9. よくある質問
  10. useReducer ではなく useState や状態管理ライブラリでも作れますか?
  11. 複数のjobを同時に表示したい場合は?
  12. 進捗率をどうしても出したいときは?
  13. まとめ

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待ち 「待機中」・statusqueue_remainingがあれば残り件数 Cancel
running ノード実行中 「生成中」・ノード名・progressがあればバー Cancel
success 完了 最終画像 New / Retry
error execution_errorまたは通信失敗 エラー要約 Retry / New
cancelled execution_interrupted 「中断しました」 Retry / New

queuedrunningを分けるのは、待ち時間の原因を伝えるためです。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公式リファレンスで確認)。状態が固定されているので矛盾した画面は作れません。公式仕様ではexecutingnodenullのとき完了なので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をsessionStoragelocalStorageに保存し、マウント時にサーバーへ現在の状態を問い合わせて復元します。

// 保存: 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状態です。SUBMITTEDqueuedに戻せば、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を保存してサーバーから復元すれば、リロードしても生成を見失いません。

スポンサーリンク