🐰

useSyncExternalStoreを使った音声状態管理

2024/09/24に公開

Reactアプリケーションで音声の再生状態を管理する際、どのような方法を使っていますか?useStateuseEffect を用いて状態を管理することが多いかもしれません。しかし、これらの方法では状態が不安定になったり、管理が複雑になったりすることがあります。

この記事では、音声状態管理のいくつかのアプローチを紹介し、最終的にuseSyncExternalStore を使った最も信頼性の高い方法を提案します。

useSyncExternalStore とは?

useSyncExternalStore は、React 18で導入されたフックで、外部ストアの状態をReactコンポーネントに同期的に反映させるためのものです。これにより、コンポーネントのライフサイクルに合わせて自動的に外部状態を購読し、更新を行うことができます。
https://ja.react.dev/reference/react/useSyncExternalStore#parameters

React における音声状態管理の実現

音声状態を管理する方法にはいくつかのアプローチがあります。手動での状態管理から、useStateuseRef を用いた方法、そして最終的にはuseSyncExternalStore を使った方法まで、それぞれの特徴と課題を比較しながら見ていきます。ここからは、これらの方法を一つずつ詳しく見ていき、最終的に最も適した方法を考えていきます。

useState で手動で音声状態を管理する方法

まずは、最もシンプルな手動で音声状態を管理する方法を見ていきましょう。この方法では、<audio> 要素に対してonPlayonPause のイベントハンドラーを手動で設定し、再生状態を useState で管理します。

import { useState } from 'react';
import drumSound from '../assets/drum.mp3';

export function AudioPlayerManual() {
  const [isPlaying, setIsPlaying] = useState(false);

  const onPlay = () => setIsPlaying(true);
  const onPause = () => setIsPlaying(false);

  return (
    <div>
      <h2>Manual Audio Player</h2>
      <p>The audio is {isPlaying ? 'playing' : 'paused'}.</p>
      <audio src={drumSound} controls onPlay={onPlay} onPause={onPause} />
    </div>
  );
}

この方法では、 onPlayonPauseのイベントハンドラーを設定して、音声の再生状態が変わるたびに isPlaying の状態を更新しています。しかし、これでは状態の管理が手動であるため、実際の再生状態とReactの状態が一致しないことがあります。
特に、複数のコンポーネントで同じ音声要素を管理しようとすると、状態が一致せず不整合が発生する可能性があります。また、他の要素や外部の操作で音声が制御される場合も、状態の同期が難しくなります。

useStateとイベントを用いて音声状態を管理する方法

次に、 useState とイベントハンドラーを組み合わせて音声状態を管理する方法を見ていきます。この方法では、 syncPlayingState という関数を用いて、音声の再生状態が変わるたびに isPlaying の状態を更新します。イベントが発生するたびに、 currentTarget.paused を確認し、再生中かどうかを判断する仕組みです。

import { useState } from 'react';
import drumSound from '../assets/drum.mp3';

export function AudioPlayerWithState() {
  const [isPlaying, setIsPlaying] = useState(false);

  const syncPlayingState: React.EventHandler<
    React.SyntheticEvent<HTMLAudioElement>
  > = (e) => setIsPlaying(e.currentTarget.paused ? false : true);

  return (
    <div>
      <h2>Audio Player With State</h2>
      <p>The audio is {isPlaying ? 'playing' : 'paused'}.</p>
      <audio
        src={drumSound}
        controls
        /**
         * propsが増えてしまう可能性がある
         * propsに指定し忘れると、再生状態が同期されない
         */
        onPlay={syncPlayingState}
        onPause={syncPlayingState}
      />
    </div>
  );
}

この方法の利点は、手動で状態を変更する必要がなくなり、イベントハンドラーにより再生状態が自動的に更新されるため、管理が容易になることです。特に、 onPlayonPause イベントがトリガーされるたびに isPlaying の状態が更新されるので、音声の再生状態とReactの状態の同期が手動管理よりもスムーズに行えます。

一方で、この方法にはいくつかの課題もあります。例えば、複数のコンポーネントで同じ音声状態を管理しようとすると、コードが重複してしまうため、管理が煩雑になります。また、イベントハンドラーを設定し忘れると、状態が正しく反映されず、予期しない動作が発生する可能性もあります。

useRef を用いて音声状態を管理する方法

次に、 useRef を使って音声状態を管理する方法を見ていきます。 useRefを使うことで、 <audio> 要素に直接アクセスし、その状態を管理します。この方法では、 useRef を使って音声要素の状態を参照し、 useEffect フックを用いて再生状態が変更された際に isPlaying の状態を更新します。

import { useEffect, useRef, useState } from 'react';
import drumSound from '../assets/drum.mp3';

export function AudioPlayerWithRefAndState() {
  const [isPlaying, setIsPlaying] = useState(false);
  const audioRef = useRef<HTMLAudioElement>(null);

  useEffect(() => {
    if (!audioRef.current) {
      return;
    }

    const syncPlayingState = () => {
      setIsPlaying(!audioRef.current?.paused);
    };

    audioRef.current.addEventListener('play', syncPlayingState);
    audioRef.current.addEventListener('pause', syncPlayingState);

    return () => {
      if (!audioRef.current) {
        return;
      }

      audioRef.current.removeEventListener('play', syncPlayingState);
      audioRef.current.removeEventListener('pause', syncPlayingState);
    };
  }, []);

  return (
    <div>
      <h2>Audio Player with Ref and State</h2>
      <p>The audio is {isPlaying ? 'playing' : 'paused'}.</p>
      <audio ref={audioRef} src={drumSound} controls />
    </div>
  );
}

この方法の利点は、 audioRef を用いることで音声要素を直接操作でき、音声の再生状態の変更に対してすばやく対応できる点です。 useEffect を使って一度だけイベントリスナーを設定すれば良いため、手動での設定忘れを防ぐことができます。また、音声要素の操作を一元管理することができ、より直感的に状態を管理することができます。

一方で、 useRefuseEffect を使ってイベントリスナーを登録し、状態を管理するという手法は、コードの意図が少し伝わりづらいというデメリットがあります。特に、 useEffect を用いてイベントリスナーを設定・解除するという処理は、 audio 要素の再生状態を管理するための副作用として見なされがちで、理解しにくい部分だと考えています。

ただし、 useSyncExternalStore が登場する前までは、 useRefuseEffect を組み合わせるこの方法が、音声状態管理において最も信頼性が高い実装方法であると思います。

useSyncExternalStore を用いて音声状態を管理する方法

最後に、 useSyncExternalStore を使って音声状態を管理する方法を紹介します。この方法では、外部ストアとして <audio> 要素の状態を扱い、再生状態をReactコンポーネントに同期的に反映させることができます。

import drumSound from '../assets/drum.mp3';
import { useCallback, useRef, useSyncExternalStore } from 'react';

function useAudioState() {
  const audioRef = useRef<HTMLAudioElement>(null);

  const getIsPlayingSnapshot = () =>
    audioRef.current ? !audioRef.current.paused : false;

  const subscribeToIsPlaying = useCallback((callback: () => void) => {
    if (audioRef.current) {
      audioRef.current.addEventListener('play', callback);
      audioRef.current.addEventListener('pause', callback);
    }

    return () => {
      if (audioRef.current) {
        audioRef.current.removeEventListener('play', callback);
        audioRef.current.removeEventListener('pause', callback);
      }
    };
  }, []);

  const isPlaying = useSyncExternalStore(
    subscribeToIsPlaying,
    getIsPlayingSnapshot
  );

  return {
    audioRef,
    isPlaying,
  };
}

export function AudioPlayerWithSyncExternalStore() {
  const { audioRef, isPlaying } = useAudioState();

  return (
    <div>
      <h2>Audio Player With Sync External Store</h2>
      <p>The audio is {isPlaying ? 'playing' : 'paused'}.</p>
      <audio ref={audioRef} src={drumSound} controls />
    </div>
  );
}

この方法では、 useSyncExternalStore を使って音声状態を管理します。 useSyncExternalStore は、 subscribe 関数を用いて外部ストア(この場合は <audio> 要素の再生状態)を監視し、状態が変わるたびにReactコンポーネントに変更を反映します。これにより、 isPlaying の状態を常に最新の状態に保つことができます。

この方法の利点は、外部ストアとして音声要素を扱うことで、複数のコンポーネントで同じ音声状態を共有しやすくなる点です。 useSyncExternalStore を使うことで、音声の再生状態が変わるたびにコンポーネントが自動的に再レンダリングされ、音声状態の一貫性を保ちながら、複雑なコードを書く必要がなくなります。
また、 useRefuseEffect を使用する方法に比べて、処理の意図がはっきりし、読み解きやすくなっています。

まとめ

このように、音声状態管理にはいくつかのアプローチがありますが、 useSyncExternalStore を使うことで、よりシンプルかつ信頼性の高い実装が可能になります。特に、複数のコンポーネント間で状態を共有しながら管理する場合や、再レンダリングの制御を簡単に行いたい場合に非常に有効です。

useRefuseEffect を用いた方法も問題なく音声状態を管理することができますが、 useSyncExternalStore を使うことで、より意図が明確で管理しやすいコードを記述することができると考えています。

サンプルコード

https://github.com/ryomaejii/sync-audio-state-with-use-sync-external-store

Discussion