Sharing Audio in React with useContext

April 21, 2020

I ❤️ React Hooks, and my favorite lately is useContext.

I was working with YKYZ recently, an audio-based social network. They have a feature that lets you listen to all Bleats (audio uploads) one after the other. But the mechanism was breaking.

Most browsers now disable autoplaying audio by requiring a user interaction to play() an Audio instance. I figured the bug had something to do with this, so after confirming the behavior on several browsers I started digging into the code. I noticed that every Bleat component had its own Audio instance, and the autoplay was coordinated by a higher-level component.

After reading some MDN docs, I realized that most browsers must implement the interaction component per-Audio rather than per-pageload, which was an assumption in the existing code. I threw together a tiny proof of concept to see if detecting the end of the clip, swapping the src, and playing again could get around the interaction requirement, and it did. But how to implement a shared audio context in React?

Sharing audio like Jim and Pam Sharing audio like Jim and Pam

Context vs Props

Most times when a codebase needs to share state, people jump to Redux. As a quick overview, Redux implements the Dispatcher pattern from Flux - basically just a JSON store, but funneling all the mutations to that store through a central point to ensure all those different mutations are exlicitly defined. react-redux is then used to pass this JSON store and dispatcher down through React Context, and connect is used to patch this context into the component's props.

None of this is really necessary, especially just to share an Audio instance. Also, Redux wasn't in the codebase I was using and this wasn't the right reason to introduce it. All we really want to do here is decouple the Audio itself, the control of that Audio, and the display of the current state of the Audio. We can express all of that in React through components.

When inverting control of the Audio, this takes the form of putting the shared Audio component toward the top of the tree rather than the bottom, where each Bleat had its own Audio instance. We could pass the instance down through props but that could end up really messy, because the components which use it might be in arbitary positions in the component tree. The solution here is context, the same mechanism react-redux uses to share Redux's store down to connect.

Visualizing the options

We can use a simplified component hierarchy to show the differences between the context and props approaches.

Before our changes:

<Playlist>
  <Bleat>
    <Audio />
  </Bleat>
  <Bleat>
    <Audio />
  </Bleat>
</Playlist>

Using props to invert control of the audio:

<Audio>
  <Playlist audio={audio}>
    <Bleat audio={audio} />
    <Bleat audio={audio} />
  </Playlist>
</Audio>

Using context to pass the audio down without threading props:

<AudioProvider>
  <Playlist>
    <Bleat />
    <Bleat />
  </Playlist>
</Audio>

Sharing the audio

We want to share an Audio instance. We could also put it into a ref to defer its instantiation to the component's lifecycle, but this works for now.

const audio = new Audio();

We start by defining a context, and initializing it with the Audio element we just created. We could also pass null null so consumers of the context know if it's been initialized or not, but we have it so we might as well use it. The null pattern is also common if you are loading something asynchronously that you'll provide later.

const AudioContext = React.createContext(audio);

Now we want to define a provider component. Notice we're exporting this - AudioContext.Provider could just go straight into the app, but breaking it out allows us to package this nicely into a file without exporting the AudioContext we created above.

It also allows you do define more complex hooks in your Provider and pass them down, but we don't need that here. Passing audio here could instead be a return value of useRef or useState rather than just the constant above.

export const AudioProvider = ({children}) => (
  <AudioContext.Provider value={audio}>{children}</AudioContext.Provider>
);

Now how do we get access to the audio further down the tree? First we need to wrap any components that want to use it with the Provider we created:

const App = () => (
  <AudioProvider>
    <Playlist />
  </AudioProvider>
);

We can wrap the useContext hook, again to avoid exporting the AudioContext:

export const useAudio = React.useContext(AudioContext);

Now we can use our custom hook down the tree with useAudio:

const Playlist = () => {
  const audio = useAudio();

  const play = React.useCallback((src) => {
    audio.src = src;
    audio.play();
  }, [audio]);

  ...
};

Making a playlist

Now that we have a shared audio context, let's make it do something. We'll centralize control into the Playlist component. It will hold info about all the clips to play (in a real application this would come from the server, but we're just using a constant for now). Let's add some state to the Playlist:

const [isPlaying, setIsPlaying] = React.useState(false);
const [currentIndex, setCurrentIndex] = React.useState(null);
const [isPlayingAll, setIsPlayingAll] = React.useState(false);

When the clip ends, let's move to the next one. We create this event listener inside a useEffect to allow cleanup when the component unmounts:

const maybeAdvancePlaylist = React.useCallback(() => {
  const newIndex = currentIndex + 1;

  // stop at the last clip
  if (newIndex > BLEATS.length) {
    return;
  }

  // don't advance if a single clip was played
  if (isPlayingAll) {
    setCurrentIndex(newIndex);
    audio.src = BLEATS[newIndex].src;
    audio.play();
  }
}, [currentIndex, isPlayingAll]);

React.useEffect(() => {
  audio.addEventListener('ended', maybeAdvancePlaylist);

  // when unmounting, clean up the event listener we've added
  return () => {
    audio.removeEventListener('ended', maybeAdvancePlaylist);
  };
}, [audio, maybeAdvancePlaylist]);

Let's see it!

The following audio files should play through, using a shared audio context:

first
second
third

Hooks are fun

The context + hooks approach has several advantages:

  1. it doesn't use any external libraries, but you could easily share Howler this way, for instance
  2. you can bring the AudioContext file over to another project easily without altering its Redux system
  3. we can provide this functionality surgically, only touching the components we need, by not threading props
  4. the changes to the codebase are obvious - useAudio can't be much clearer in my opinion

But the best part is that it's fun! Hooks are super simple to write, refactor, and abstract. And in my opinion frontend work is fun because you get to interact with it - audio adds on top of the visual part of that!

Thanks

Thanks to Dave and Dylan for helping proof read this post (my first in a couple years!) and to YKYZ for allowing me to write about this work.

🙂