7
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React × 動画系SDKの実装では div を一枚はさもう (divコンテナパターン)

7
Last updated at Posted at 2026-03-16

はじめに

現在弊社ではスポーツメディアサービス"スポーツブル"の大規模なリプレイスが進行中で、フロントエンドについては2月末にリリースを行いました。
フロントエンドは、従来のイベントページごとに Vue の開発環境があり開発環境の乱立状態から、 Next.js (App Router) へと全面的にリプレイスしました。

また、スポーツブルではスポーツのライブ映像配信を行っています。
そのために OVP (Online Video Platform) として BrightcoveSTREAKS (PLAY社), YouTube などを使用しており、フロントエンドではそれに対応した動画プレーヤーを使用しています。(Brightcove, STREAKS は video.js ベースの SDK が提供されています)

リプレイスにあたって、それら動画プレーヤーを React で書き直すことになったわけですが、その際にややハマった点を記載します。

課題

Failed to execute 'removeChild' on 'Node'

React のSPA(CSR遷移)で素朴に <video> 要素を JSX で書いて SDK に渡すと、ページ遷移時に React が管理しているはずの DOM を、SDK が先に消してしまうことによって発生しています。

TL;DR

方式 React が管理する要素 SDK が管理する要素 CSR遷移
素朴な方式 <video> <video> (同じ) 💥 衝突
divコンテナパターン <div> のみ <video> (動的生成) ✅ 安全

コア原則: React と SDK の DOM 管理範囲を完全に分離する。 これを個人的な命名ですが "divコンテナパターン" と呼ぶことにします。

動画プレーヤーSDKに限らず、SDK側がDOM操作を行うタイプのSDKは同様の実装で対処できるはずです。

この原則は React ドキュメントの以下で述べられています。本記事ではこれを動画プレーヤー SDK に適用する際の具体的な実装パターン(Strict Mode 対策、広告プラグインとの共存、SDK 別の初期化・破棄)を扱います。

https://react.dev/learn/manipulating-the-dom-with-refs#best-practices-for-dom-manipulation-with-refs

You can safely modify parts of the DOM that React has no reason to update. For example, if some <div> is always empty in the JSX, React won’t have a reason to touch its children list. Therefore, it is safe to manually add or remove elements there.

目次

  1. 問題:なぜ衝突が起きるのか
  2. 解決策:divコンテナパターン
  3. SDK別の実装
  4. ネイティブ <video> との使い分け
  5. React 18 Strict Mode への対応
  6. 広告プラグインとの共存
  7. まとめ

問題:なぜ衝突が起きるのか

素朴なアプローチ

// ❌ よくある実装:JSX で <video> を書き、ref で SDK に渡す
function NaivePlayer({ videoId }: { videoId: string }) {
  const videoRef = useRef<HTMLVideoElement>(null)

  useEffect(() => {
    if (!videoRef.current) return
    const player = videojs(videoRef.current, { /* options */ })

    return () => {
      player.dispose() // 💥 ここで問題が起きる
    }
  }, [videoId])

  return <video ref={videoRef} className="video-js" />
}

何が起きるか

  1. CSR遷移(Next.js の router.push() 等)でコンポーネントがアンマウントされる
  2. cleanup 関数で player.dispose() が呼ばれる
  3. dispose() は SDK が管理する <video> 要素とその子要素を DOM から削除する
  4. React も同じ <video> 要素の削除を試みるremoveChild エラー
Uncaught DOMException: Failed to execute 'removeChild' on 'Node':
The node to be removed is not a child of this node.

根本原因はReact と SDK の両方が同じ DOM ノードの所有権を主張していることです。

React の仮想DOM:  <div> → <video ref={videoRef} />  ← React が管理
SDK の内部状態:    <video> → コントロールUI、広告レイヤー等  ← SDK が管理
                      ↑ 同じノード!

解決策:divコンテナパターン

React には <div> コンテナだけを管理させ、<video> 要素は useEffect 内で動的に生成する。

React の仮想DOM:  <div ref={containerRef} />  ← React が管理(これだけ)
SDK の内部状態:    <div> → <video> → コントロールUI  ← SDK が管理
                    ↑ React は中身を知らない

これにより、dispose()<video> 以下を全て削除しても、React は <div> しか管理していないため衝突しません。

基本テンプレート(video.js の例)

function SafePlayer({ videoId }: { videoId: string }) {
  const containerRef = useRef<HTMLDivElement>(null)
  const playerRef = useRef<Player | null>(null)

  useEffect(() => {
    if (!containerRef.current) return

    // 1. コンテナをクリア(Strict Mode 対策)
    containerRef.current.innerHTML = ''

    // 2. <video> 要素を動的に生成
    const videoEl = document.createElement('video')
    videoEl.className = 'video-js'
    videoEl.controls = true
    videoEl.playsInline = true
    containerRef.current.appendChild(videoEl)

    // 3. SDK で初期化
    const player = videojs(videoEl, { /* options */ })
    playerRef.current = player

    // 4. Cleanup: SDK の dispose() に全てを任せる
    return () => {
      if (playerRef.current) {
        playerRef.current.dispose()
        playerRef.current = null
      }
      // 念のためコンテナもクリア
      if (containerRef.current) {
        containerRef.current.innerHTML = ''
      }
    }
  }, [videoId])

  // React は <div> だけを描画
  return <div ref={containerRef} />
}

SDK別の実装

video.js

素の video.js の場合、前述の基本テンプレートがそのまま適用できます。コード例は基本テンプレートを参照してください。

ポイント: dispose() は video.js の内部状態(IMA3 広告プラグインの再生済みフラグなど)もリセットします。CSR遷移で同じ記事に戻った際に広告が正しく再初期化されるためには、この完全なリセットが必須です。

STREAKS

STREAKS は video.js ベースの動画配信SDKです。divコンテナパターンの部分(<video> の動的生成、dispose() による cleanup)は video.js と同じですが、初期化に STREAKS 固有の API(window.streaksplayer() / PlaybackApi)を使います。

なお、STREAKS SDK はスクリプトの動的ロードが必要です。実際のプロダクションコードでは SDK ロード完了を state で管理し、ロード完了後に別の useEffect でプレーヤーを初期化する二段階のアプローチを取っています。ここでは SDK ロード済みを前提とした簡略版を示します。

function StreaksPlayer({ projectId, mediaId, apiKey, autoPlay, muted }: StreaksProps) {
  const containerRef = useRef<HTMLDivElement>(null)
  const playerRef = useRef<StreaksPlayerInstance | null>(null)

  useEffect(() => {
    if (!containerRef.current) return

    // Strict Mode: 前回のマウントの残骸をクリア
    containerRef.current.innerHTML = ''

    // <video> を動的生成(video.js と同じ)
    const videoEl = document.createElement('video')
    videoEl.id = `streaks-${mediaId}`
    videoEl.className = 'video-js'
    videoEl.controls = true
    videoEl.muted = muted
    videoEl.playsInline = true
    containerRef.current.appendChild(videoEl)

    // STREAKS SDK でプレーヤーを作成(window.streaksplayer は SDK のグローバル関数)
    const player = window.streaksplayer(videoEl.id, { muted })
    playerRef.current = player

    // Playback API でメディアを取得し、player.loadMedia() で読み込む
    const api = new window.streaks.api.PlaybackApi({
      host: 'playback.api.streaks.jp',
      project: projectId,
      apiKey,
    })
    api.params({ media_id: mediaId })
    api.request((err, playbackJson) => {
      if (err) return
      player.loadMedia(playbackJson, { autoplay: autoPlay, muted })
    })

    // Cleanup は video.js と同じ
    return () => {
      if (playerRef.current) {
        playerRef.current.dispose()
        playerRef.current = null
      }
      if (containerRef.current) {
        containerRef.current.innerHTML = ''
      }
    }
  }, [projectId, mediaId, apiKey, autoPlay, muted])

  return <div ref={containerRef} />
}

STREAKS 固有のポイント:

  • window.streaksplayer() が STREAKS SDK のプレーヤー生成関数(video.js の videojs() に相当)
  • PlaybackApi でメディア情報を取得し、player.loadMedia() で広告設定とともに読み込む
  • SDK のスクリプトロードが非同期のため、プロダクションでは isSdkLoaded state による二段階初期化が必要

Brightcove

Brightcove は video.js ベースですが、独自の初期化フローがあります。

function BrightcovePlayer({ videoId, accountId, autoPlay, muted }: BrightcoveProps) {
  const containerRef = useRef<HTMLDivElement>(null)
  const playerRef = useRef<BrightcoveVideoJsPlayer | null>(null)

  useEffect(() => {
    if (!containerRef.current) return
    let destroyed = false

    // Strict Mode 対策
    containerRef.current.innerHTML = ''

    // <video> を動的生成(data 属性で Brightcove に情報を渡す)
    const videoEl = document.createElement('video')
    videoEl.id = `brightcove-${videoId}`
    videoEl.dataset.videoId = videoId
    videoEl.dataset.account = accountId
    videoEl.dataset.player = 'default'
    videoEl.dataset.embed = 'default'
    videoEl.className = 'video-js'
    videoEl.controls = true
    videoEl.muted = muted
    videoEl.playsInline = true
    containerRef.current.appendChild(videoEl)

    // Brightcove Player スクリプトを注入
    const script = document.createElement('script')
    script.src = `//players.brightcove.net/${accountId}/default_default/index.min.js`
    document.head.appendChild(script)

    // スクリプトによる自動初期化の完了を待つ(非同期のためポーリング)
    const pollId = setInterval(() => {
      if (destroyed) { clearInterval(pollId); return }
      if (typeof videojs === 'undefined') return

      const player = videojs.getPlayer(videoEl.id)
      if (!player) return

      clearInterval(pollId)
      playerRef.current = player

      player.ready(() => {
        if (destroyed) return
        // ✅ autoplay は HTML 属性ではなく player.ready() 内で制御
        if (autoPlay) player.play()
      })
    }, 200)

    return () => {
      destroyed = true
      clearInterval(pollId)
      if (playerRef.current) {
        playerRef.current.dispose()
        playerRef.current = null
      }
      if (containerRef.current) {
        containerRef.current.innerHTML = ''
      }
      // スクリプトも削除(次回マウント時にフレッシュなスクリプトをロード)
      script.remove()
    }
  }, [videoId, accountId, autoPlay, muted])

  return <div ref={containerRef} />
}

Brightcove 固有の注意点:

  1. スクリプトによる自動初期化: Brightcove Player のスクリプトは <video> の data 属性を自動検出してプレーヤーを初期化するが、CSR遷移などで再マウントした場合、スクリプトのキャッシュにより自動検出が動かないことがある。videojs.getPlayer() でポーリングすることで確実に取得する。

  2. スクリプトの差し替え: cleanup 時に script.remove() で古いスクリプトを削除し、次のマウントで新しいスクリプトを追加する。これにより IMA3 広告プラグインの UI が正しく再構築される。

  3. autoplay の扱い: HTML の autoplay 属性は使わない。スクリプト再実行のタイミング次第で広告プラグインと競合するため、player.ready() 内で player.play() を明示的に呼ぶ。プロダクションではブラウザの自動再生ポリシーで play() が拒否された場合のミュート再試行も必要。

参考:
Brightcove の React 実装としては React Player Loader という、 Brightcove が公開しているが公式サポート対象外のパッケージがありますが、サポート対象外なのとやや古そうなので使用していません。
https://player.support.brightcove.com/coding-topics/react-player-loader.html

The React Player Loader is an open source tool and is NOT officially supported by Brightcove. Also, this library supports only Brightcove Players v6.11.0 and higher.

YouTube IFrame API

YouTube は video.js 系ではありませんが、同じ原則が適用できます。YT.Player API は渡された <div><iframe>置換する仕組みです。

function YouTubePlayer({ videoId, autoPlay, muted }: YouTubePlayerProps) {
  const containerRef = useRef<HTMLDivElement>(null)
  const ytPlayerRef = useRef<YT.Player | null>(null)

  // SSR/CSR で安定した ID を生成
  const reactId = useId()
  const placeholderId = `yt-player${reactId.replace(/:/g, '-')}`

  useEffect(() => {
    let destroyed = false

    loadYouTubeApi().then(() => {
      if (destroyed) return
      if (!document.getElementById(placeholderId)) return

      // YT.Player が placeholderId の <div> を <iframe> に置換
      ytPlayerRef.current = new YT.Player(placeholderId, {
        videoId,
        width: '100%',
        height: '100%',
        playerVars: {
          autoplay: autoPlay ? 1 : 0,
          mute: muted ? 1 : 0,
          playsinline: 1,
          origin: window.location.origin,
        },
        events: {
          onReady: () => { /* ... */ },
          onStateChange: (e) => {
            switch (e.data) {
              case YT.PlayerState.PLAYING: /* ログ開始 */ break
              case YT.PlayerState.PAUSED:  /* ログ停止 */ break
              case YT.PlayerState.ENDED:   /* ログ停止 */ break
            }
          },
        },
      })
    })

    return () => {
      destroyed = true
      if (ytPlayerRef.current) {
        ytPlayerRef.current.destroy() // iframe を削除 + API 状態をリセット
        ytPlayerRef.current = null
      }
    }
  }, [videoId, autoPlay, muted, placeholderId])

  return (
    <div ref={containerRef}>
      <div id={placeholderId} />
    </div>
  )
}

YouTube 固有のポイント:

  • destroy() が iframe の削除まで行うため、innerHTML = '' は不要
  • useId() で SSR/CSR の hydration ミスマッチを防ぐ
  • origin パラメータは postMessage のセキュリティに必要

ネイティブ <video> との使い分け

全ての動画プレーヤーにdivコンテナパターンが必要なわけではありません。

SDK を使わないネイティブ <video> は、React に直接管理させて問題ありません。

// ✅ ネイティブ <video> — React 管理で OK
function NativePlayer({ src, autoPlay }: { src: string; autoPlay?: boolean }) {
  const videoRef = useRef<HTMLVideoElement>(null)

  // autoplay 制御 + CSR遷移時の再生停止
  // HTML の autoPlay 属性ではなく useEffect で制御することで、
  // Strict Mode の cleanup でタイマーがキャンセルされ二重発火を防止する
  useEffect(() => {
    const videoEl = videoRef.current
    if (!videoEl) return

    let timer: ReturnType<typeof setTimeout> | null = null
    if (autoPlay) {
      // setTimeout(0) で次のタスクに遅延させ、
      // Strict Mode の即座の unmount でキャンセルされるようにする
      timer = setTimeout(() => {
        videoEl.play().catch(() => {
          // 自動再生がブロックされた場合、ミュートで再試行
          videoEl.muted = true
          videoEl.play().catch(() => {})
        })
      }, 0)
    }

    return () => {
      // タイマーキャンセル + 再生停止
      if (timer) clearTimeout(timer)
      videoEl.pause()
    }
  }, [autoPlay])

  return (
    <video
      ref={videoRef}
      src={src}
      controls
      playsInline
      onPlay={() => { /* ... */ }}
      onPause={() => { /* ... */ }}
    />
  )
}

判断基準

条件 方式
SDK が DOM を自前管理する(video.js, Brightcove, YouTube API) divコンテナパターン
ネイティブ <video> をそのまま使う(HLS, mp4, WebM) React 管理
SDK が React コンポーネントを提供している(react-player 等) SDK のコンポーネントに従う

判断のコツ: SDK に dispose()destroy() メソッドがあるなら、divコンテナパターンが必要と考えてほぼ間違いありません。


React 18 Strict Mode への対応

React 18 の Strict Mode は開発時にエフェクトを2回実行します(mount → unmount → mount)。

divコンテナパターンでは、2回目の mount 時に前回の残骸がコンテナ内に残っている可能性があります。

useEffect(() => {
  if (!containerRef.current) return

  // ✅ エフェクト開始時にコンテナをクリア
  // → 1回目の mount の残骸があっても安全
  containerRef.current.innerHTML = ''

  const videoEl = document.createElement('video')
  containerRef.current.appendChild(videoEl)
  // ...SDK 初期化...

  return () => {
    // ✅ Cleanup でもクリア
    playerRef.current?.dispose()
    if (containerRef.current) {
      containerRef.current.innerHTML = ''
    }
  }
}, [/* deps */])

タイムライン(Strict Mode):

1st mount:  innerHTML='' → createElement → SDK init
1st unmount: dispose() → innerHTML=''
2nd mount:  innerHTML='' → createElement → SDK init  ← ここが本番の mount
2nd unmount: (コンポーネント本来の unmount)

innerHTML = '' を mount と cleanup の両方で行うことで、どのタイミングでも安全になります。

ネイティブ <video> の場合は innerHTML クリアではなく、前述の setTimeout(0) + cleanup での clearTimeout パターンで対応します。Strict Mode の 1st unmount で clearTimeout によりタイマーがキャンセルされるため、play() の二重発火を防止できます。


広告プラグインとの共存

video.js 系の広告プラグイン(IMA3 等)は、プレーヤー初期化時に play → pause → play という一連のイベントを発火させることがあります。これをそのまま受け取ると、ログや分析で誤った再生開始イベントを記録してしまいます。

対処法は SDK や広告プラグインの構成によって異なりますが、我々のケースでは以下の2つのアプローチで対応しました。

  • デバウンス: play イベントを即座に処理せず 200ms 待ち、その間に pause が来たらキャンセルすることで、初期化チェーンを吸収する(STREAKS で採用)
  • フラグ管理: ads-ad-started / ads-ad-ended イベントで広告再生中フラグを管理し、広告中の play イベントを無視する(Brightcove で採用)

どちらが必要かは実際にイベントログを確認してから判断するのが確実です。まずフラグ管理のみで実装し、play → pause → play の連鎖が確認されたらデバウンスを追加する段階的アプローチがおすすめです。


まとめ

divコンテナパターンの全体像

┌─ React の責務 ────────────────────────────────────┐
│  <div ref={containerRef} />                        │
│  ↑ React はこの div のライフサイクルだけを管理      │
└───────────────────────────────────────────────────-─┘

┌─ useEffect 内 ────────────────────────────────────┐
│  mount:                                            │
│    containerRef.innerHTML = ''    // Strict Mode    │
│    videoEl = createElement('video')                │
│    containerRef.appendChild(videoEl)               │
│    SDK.init(videoEl)              // SDK に委譲     │
│                                                    │
│  cleanup:                                          │
│    player.dispose()               // SDK が全管理  │
│    containerRef.innerHTML = ''    // 念のためクリア │
└────────────────────────────────────────────────────┘

SDK別の対応表

SDK 初期化 破棄 Strict Mode 広告対応
video.js videojs(videoEl, options) dispose() + innerHTML='' innerHTML='' SDK構成による
STREAKS streaksplayer() + PlaybackApi dispose() + innerHTML='' innerHTML='' デバウンス
Brightcove スクリプト注入 + ポーリング dispose() + innerHTML='' + script.remove() innerHTML='' フラグ管理
YouTube new YT.Player(divId) destroy() 不要(destroy() で完結) N/A
ネイティブ video React JSX pause() setTimeout(0) + clearTimeout N/A

この方式が有効な場面

  • CSR遷移のある SPA(Next.js App Router, React Router 等)
  • SDK が DOM を自前管理する動画プレーヤー
  • 広告プラグインが内部状態を持つケース
  • React 18 Strict Mode で開発している場合

「React の管理範囲」と「SDK の管理範囲」を明確に分離するだけで、多くの動画SDK関連バグを予防できます。困ったら div を一枚挟む ——シンプルですが効果的なパターンです。

最後に

運動通信社は「日本を世界が憧れるスポーツ大国にする」というビジョンを達成するべく、一緒に働く仲間を募集しています!
PMやアプリエンジニア、Webエンジニアなど色んな職種を募集しておりカジュアル面談大歓迎なので是非採用フォームよりご連絡ください!
ぜひ一緒に、自分たちも世の中もワクワクさせるサービスを作りましょう!

7
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?