Sharing Audio in React with useContext
April 21, 2020I ❤️ 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
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;
if (newIndex > BLEATS.length) {
return;
}
if (isPlayingAll) {
setCurrentIndex(newIndex);
audio.src = BLEATS[newIndex].src;
audio.play();
}
}, [currentIndex, isPlayingAll]);
React.useEffect(() => {
audio.addEventListener('ended', maybeAdvancePlaylist);
return () => {
audio.removeEventListener('ended', maybeAdvancePlaylist);
};
}, [audio, maybeAdvancePlaylist]);
Let's see it!
The following audio files should play through, using a shared audio context:
Hooks are fun
The context + hooks approach has several advantages:
- it doesn't use any external libraries, but you could easily share Howler this way, for instance
- you can bring the
AudioContext
file over to another project easily without altering its Redux system - we can provide this functionality surgically, only touching the components we need, by not threading props
- 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.