7 months ago

What is a custom hook ?

Hooks are a new feature of React 16.8. They let you have a local state and access other React features without having to use classes.

React documentation

A custom hook lets you compose those complex React features, and encapsulate them into a function. This then allows to reuse non-rendering logic between React components.

This article assumes you have a basic understanding of the useState and useEffect hooks.

The Audio object in Javascript

To play sound, one might use the Audio object in Javascript.

Let's say for the sake of the example we have an URL to a music file, that we might have pulled from an API such as Spotify.

It can be instantiated as such :

const url = '<http://mysite.fr/myaudiofile.mp3>';

const audioObj = new Audio(url);

You then get an audio object, that has methods to control playback.

audioObj.play();
audioObj.pause();

One could also subscribe to events on this object, for example to run code when the playback completes :

audio.addEventListener('ended', () => {
	console.log('la lecture est terminée');
});

Our React component

Now let's say we want to create a component that controls playback.

JSX would look something like this :

<Button onClick={togglePlayBack} icon labelPosition="left">
  <Icon name={playing ? 'pause' : 'play'} />
  {playing ? 'Pause' : 'Play'}
</Button>

When the button is clicked, we call a togglePlayBack function that will stop or start the playback. We also need a playing boolean so we can know if the music is currently playing or not.

Let's start by declaring this playing state, and the togglePlayBack function that inverts its value.

const [playing, setPlaying] = useState(false);
const togglePlayBack = () => setPlaying(!playing);

We will now create an audio object that will control playback. Lets say our component receives a music url as a prop :

const AudioPlayer = ({ url }) => {

However, we want to create it only once, and not on each render ! If not, every time we click play or pause that object would be recreated, and sound would play over and over. We can use the useState hook and only create the audio object on the first render :

const [audioObj] = useState(new Audio(url));

Finally, we want the playing state to go back to false when playback completes. To achieve that, we will create a new event listener as seen earlier. In order for the listener to be created on the initial render, we will use the useEffect hook with an empty dependency array, that acts pretty much like componentDidMount in a class component.

useEffect(() => {
    audio.addEventListener('ended', () => setPlaying(false));
  }, []);

Problem : we do not want our event listener to survive our component destruction. We can use the useEffect cleanup function to destroy it :

useEffect(() => {
  audio.addEventListener('ended', () => setPlaying(false));
	// the function below will be called when our component is destroyed
  return () => {
    audio.removeEventListener('ended', () => setPlaying(false));
  };
}, []); // empty dependency array : this effect will only run on component mount

Finally, when our component changes the value of playing, we want to play or pause the music.

We can use the useEffect hook again with playing as dependency : the function will be called only when playing changes.

// called when "playing" changes
useEffect(() => {
	// if playing is true, start playback
  if (playing) {
    audio.play();
  }
	// if playing is false, stop playback
  else {
    audio.pause();
  }
}, [playing]); // the dependency arrays tells React when to call the effect

Our final code would look something like this :

const AudioPlayer = ({ url }) => {
  const [audio] = useState(new Audio(url));
  const [playing, setPlaying] = useState(false);

  const togglePlayBack = () => setPlaying(!playing);

  useEffect(() => {
    if (playing) {
      audio.play();
    }
    else {
      audio.pause();
    }
  }, [playing]);

  useEffect(() => {
    audio.addEventListener('ended', () => setPlaying(false));
    return () => {
      audio.removeEventListener('ended', () => setPlaying(false));
    };
  }, []);

  return (
    <Button onClick={togglePlayBack} icon labelPosition="left">
      <Icon name={playing ? 'pause' : 'play'} />
      {playing ? 'Pause' : 'Play'}
    </Button>
  );
};

Creating a custom hook

Finally, we can move all this code to a custom hook, which is basically just a function: useAudio . As you can see, the function does the same code we wrote earlier, and returns the things our component will need : the playing state and the togglePlayback method.

// a custom hook to handle audio
const useAudio = (url) => {
  const [audio] = useState(new Audio(url));
  const [playing, setPlaying] = useState(false);

  const togglePlayback = () => setPlaying(!playing);

  useEffect(() => {
    if (playing) {
      audio.play();
    }
    else {
      audio.pause();
    }
  }, [playing]);

  useEffect(() => {
    audio.addEventListener('ended', () => setPlaying(false));
    return () => {
      audio.removeEventListener('ended', () => setPlaying(false));
    };
  }, []);

  return [playing, togglePlayback];
};

const AudioPlayer = ({ url }) => {
  const [playing, togglePlayback] = useAudio(url);

  return (
    <Button onClick={togglePlayback} icon labelPosition="left">
      <Icon name={playing ? 'pause' : 'play'} />
      {playing ? 'Pause' : 'Play'}
    </Button>
  );
};

For this last step, it is important to match a React rule: our hook name must start with use.

Summing up

Using custom hooks lets us wrap logic we want to reuse between components. As we have seen, our custom hooks can easily compose the base React hooks such as useState and useEffect. This makes custom hooks an essential tool to better split our code.