Article

🖼️ Reactの定期更新でPNGプレビューがちらつく理由:参照同一性と非同期競合

react, typescript, javascript, canvas

ゲームの結果やダッシュボードのグラフをPNGに変換する画面で、データが変わっていないのに画像が消えて再表示されることがあります。よくある原因は画像生成の失敗ではなく、Reactが「新しいオブジェクト」を「新しい表示内容」として扱ってしまうことです。

この記事では、定期的にサーバーと同期するReact画面を例に、参照同一性・表示内容の同値性・非同期画像生成・Blob URLの寿命を切り分けます。コードは考え方を示す最小例であり、実アプリの実装をそのまま転載したものではありません。

1. 同じ配列の中身でも、参照は別

例えば、3秒ごとにスコアを取得し直す画面で、レスポンスから表示用配列を作るとします。

function ResultPanel({ game }: { game: Game }) {
  const players = game.players.map((player) => ({
    label: player.displayName,
    score: player.score,
  }));

  return <ResultImage players={players} />;
}

ここでの問題は、mapは呼ぶたびに別の配列を返す点です。得点が同じでも、前回の配列と今回の配列は異なるオブジェクトです。

const before = [{ label: "A", score: 100 }];
const after = [{ label: "A", score: 100 }];

console.log(before === after); // false
console.log(JSON.stringify(before) === JSON.stringify(after)); // true

ReactのuseEffectは依存値をObject.isで比較します。オブジェクトの中身が同じかどうかを自動で深く比較するわけではありません。useMemoでカードを作っていても、その依存に毎回新しくなる配列を渡せば、カードは再計算され得ます。

const card = useMemo(() => buildCard(players), [players]);

useEffect(() => {
  // カードの画像を作り直す
}, [card]);

すると、データが実際には変化していないのに、カード再計算 → 以前のPNGを無効化 → 新しいPNG生成 → 再表示という流れが起こります。

React公式も、レンダリング中に作ったオブジェクトをEffectの依存にすると不要な再実行が起こり得ると説明しています。

2. 比較するのは「画面に描く情報」だけ

対策は、親が作った配列の参照と、最終的な画像の内容を区別することです。

最初に共有画像専用の小さなモデルを作ります。モデルとは、描画に必要なデータをまとめた構造のことです。

type ResultCard = {
  title: string;
  anonymous: boolean;
  rows: Array<{
    rank: number;
    label: string;
    score: string;
  }>;
};

// 画面に載せる項目だけを明示する。認証トークンや共有用URLは含めない
function toCard(
  title: string,
  players: Array<{ rank: number; name: string; score: number }>,
  anonymous: boolean,
): ResultCard {
  return {
    title,
    anonymous,
    rows: players.map((player, index) => ({
      rank: player.rank,
      label: anonymous ? "プレイヤー" + (index + 1) : player.name,
      score: formatScore(player.score),
    })),
  };
}

この例では順序も表示内容の一部です。順序を変更しない更新なら同じ文字列になり、順位や名前・スコア・匿名化設定が変われば文字列も変わります。

const cardKey = JSON.stringify(card);

このキー(表示内容の識別子)を画像生成の依存にすれば、毎回新しい配列を受け取っても、内容が同じなら画像を再生成せずに済みます。

ただし、JSON.stringifyは汎用の深い比較ツールではありません。プロパティ順、undefined、Dateなどの変換、巨大なデータを意識する必要があります。今回のように項目と順番が固定された小さな表示用モデルに限定します。大きい画面状態全体や、利用者の非公開情報を丸ごとキー化しないでください。

3. 古い非同期結果が新しい画像を上書きする問題

Canvas(ブラウザーで図形や文字を描くためのAPI)の描画が終わったら、toBlobでPNGのBlobを取得します。Blobは画像などのバイナリデータを表すオブジェクトです。

async function renderPng(card: ResultCard): Promise<Blob> {
  const canvas = document.createElement("canvas");
  canvas.width = 800;
  canvas.height = 500;

  const ctx = canvas.getContext("2d");
  if (!ctx) throw new Error("Canvas 2Dが利用できません");

  await document.fonts.ready;

  ctx.fillStyle = "#fff";
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = "#222";
  ctx.font = "bold 32px sans-serif";
  ctx.fillText(card.title, 32, 55);

  ctx.font = "24px sans-serif";
  card.rows.slice(0, 8).forEach((row, index) => {
    ctx.fillText(
      String(row.rank) + "位  " + row.label + "  " + row.score,
      32,
      105 + index * 44,
    );
  });

  return new Promise<Blob>((resolve, reject) => {
    canvas.toBlob((blob) => {
      if (blob) resolve(blob);
      else reject(new Error("PNGへの変換に失敗しました"));
    }, "image/png");
  });
}

これは固定サイズの説明用です。実運用では長い文字の幅計測、行数に応じたページ分割、フォントの対応を加えます。また、外部画像をCanvasに描く場合はCORS(異なるオリジンからの読み込み制御)によりtoBlobが失敗することがあります。

重要なのは、古い画像生成が遅れて完了しても採用しないことです。

import { useEffect, useState } from "react";

type Preview = { key: string; blob: Blob; url: string };

function ResultPreview({ card }: { card: ResultCard }) {
  // 固定スキーマの表示内容を比較する。カードのオブジェクト参照には依存しない
  const key = JSON.stringify(card);
  const [preview, setPreview] = useState<Preview | null>(null);
  const [error, setError] = useState("");

  useEffect(() => {
    let disposed = false;
    let objectUrl: string | null = null;
    // この世代のデータを固定。以後のprops変更とは切り離す
    const snapshot = JSON.parse(key) as ResultCard;

    async function generate() {
      try {
        const blob = await renderPng(snapshot);
        if (disposed) return; // 古い処理なら採用しない

        objectUrl = URL.createObjectURL(blob);
        setPreview({ key, blob, url: objectUrl });
        setError("");
      } catch {
        if (!disposed) setError("画像の生成に失敗しました");
      }
    }

    void generate();

    return () => {
      disposed = true;
      if (objectUrl) URL.revokeObjectURL(objectUrl);
    };
  }, [key]);

  // 内容が変わった瞬間、古い画像を「保存可能な現行データ」として扱わない
  const active = preview?.key === key ? preview : null;

  return (
    <section>
      {active ? (
        <>
          <img src={active.url} alt="ゲーム結果のPNGプレビュー" />
          <a href={active.url} download="result.png">PNGを保存</a>
        </>
      ) : (
        <p>画像を生成しています</p>
      )}
      {error && <p role="alert">{error}</p>}
    </section>
  );
}

この例のポイントは4つです。

  1. 依存配列を配列の参照ではなく、表示内容に対応する文字列キーにする。
  2. 新旧のキーが違う間は古い画像を保存させない。
  3. Effectのcleanup(後片付け)で古い非同期結果を無効にする。
  4. URL.createObjectURLで作ったBlob URLを、不要になったときにURL.revokeObjectURLで解放する。

Blob URLはBlobをブラウザー内で参照するためのURLです。生成し直すたびに古いURLを放置すると、不要なメモリ保持につながります。一方、表示中のURLを早すぎるタイミングで破棄すると、保存や別タブ表示を妨げます。その画像がもう使われなくなったタイミングで解放します。

このサンプルは画像の取得・保存に絞っています。スマートフォンの共有メニューに渡すなら、BlobからFileを作り、ブラウザーのファイル共有対応を確認する処理を別に追加してください。共有ボタンを押した後に非同期生成を挟むと、Web Share APIが要求する一時的なユーザー操作の権限を失う場合があります。

4. スコア表示は「データが正しい」と「画像も正しい」が別

もう一つの落とし穴は、描画時の数値の丸めです。

(1.231).toFixed(2); // "1.23"
(1.232).toFixed(2); // "1.23"
(0.004).toFixed(2); // "0.00"

順位計算が正しくても、PNG上で異なる点数が同じ文字列になってしまいます。任意の桁数で切り捨てるのではなく、少なくとも元のJavaScript Numberの文字列表現を保つ方法を検討します。

function formatScore(score: number): string {
  if (!Number.isFinite(score)) return "—";
  const text = String(score);
  // 通常の十進表現だけ整数部に桁区切りを入れる。指数表記は維持する
  const match = /^(-?)(\d+)(\.\d+)?$/.exec(text);
  if (!match) return text;
  const [, sign, integer, fraction = ""] = match;
  return sign + integer.replace(/\B(?=(\d{3})+(?!\d))/g, ",") + fraction;
}

console.assert(formatScore(1.231) === "1.231");
console.assert(formatScore(1.232) === "1.232");
console.assert(formatScore(1234567.8901) === "1,234,567.8901");
console.assert(formatScore(1e21) === "1e+21");

ただし、Numberに取り込んだ時点で失われた小数精度は復元できません。金額など正確な十進数が必要なら、元データの文字列や適切な十進数ライブラリで管理します。

また、文字列が正しくてもCanvasの描画幅によって末尾が省略される可能性があります。名前やコメントの省略ロジックをスコアに共用せず、measureTextによる幅確認やfillTextの幅指定を使い、出力PNGそのものを検査します。

5. 回帰テストは「何も変わらない更新」を含める

回帰テストとは、直した不具合が再発しないことを確認するテストです。次のケースが特に重要です。

状況 確認したいこと
同じ成績を3回取得し、毎回新しい配列を渡す PNGを再生成せず、画像とBlob URLが維持される
スコアが変わる 古いPNGを保存・共有できず、新しいPNGへ更新される
古い画像生成が後から完了 古い結果が新しい画像を上書きしない
画面を閉じる 使用終了したBlob URLが解放される
小数・大きな数・長い表示 元の数値の意味が変わらず、縦横の実PNGで読める
開発時のStrict Mode 追加のsetup/cleanupでも画像処理が破綻しない

Reactは開発時のStrict ModeでEffectのsetup/cleanupを追加実行する場合があります。「Effectは必ず1回しか実行されない」という前提で作らず、cleanupを対になる処理として実装してください。

実際のプロダクトで起きたこと

この問題は、ゲームの結果を保存・共有できるWebアプリ PointPia(Game Score Manager) の結果画像機能で調査したものです。

2026年10月25日の公開可能な更新記録では、定期同期でプレイヤー配列の参照だけが変化し、表示内容に変化がないのにPNGを再生成していたことが確認されています。同じ内容を3回渡す再現テストで、初回を含め4回の画像生成と3回のBlob URL破棄が発生しました。修正では表示用カードモデルの内容が同じなら安定した参照を保ち、変更時の古い画像の無効化は維持しました。

この修正は2026年10月25日に本番リリース済みと記録されています。本番配信コードでのUI確認は合成GETデータを使った検証であり、実利用者データや実SNSへの投稿成功を確認したという意味ではありません。

SNS共有に限らず、定期更新する帳票、グラフ、PDFプレビュー、地図のスナップショットでも、通信結果の参照・表示内容・生成結果の寿命を分離すると不具合を防ぎやすくなります。

参考資料