Tomek
Tomek

Reputation: 4839

How do I use a dynamic JS library (wavesurfer.js) in a functional React component?

I have the following functional React component that is using wavesurfer.js

import * as React from "react"
import * as WaveSurfer from 'wavesurfer.js'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlayCircle, faPauseCircle } from '@fortawesome/free-regular-svg-icons'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'

interface WaveformPlayerProps {
  audioUrl: string
}

const WaveformPlayer = (props: WaveformPlayerProps) => {
  const [playing, setPlaying] = React.useState(false)
  const [audioContext, setAudioContext] = React.useState(new AudioContext())
  const [waveform, setWaveform] = React.useState(null)

  const waveformRef = React.useRef();

  React.useEffect(() => {
    if (waveformRef.current) {
      const wavesurfer = WaveSurfer.create({
        audioContext: audioContext,
        container: waveformRef.current
      });

      wavesurfer.on('finish', togglePlayPause)
      wavesurfer.load(props.audioUrl)
      setWaveform(wavesurfer)
    }
  }, [])

  function togglePlayPause(): void {
    if (audioContext.state == "suspended") {
      audioContext.resume()
    }

    waveform.playPause()
    setPlaying(!playing)
  }

  function stateButtonIcon(): IconDefinition {
    if (playing) {
      return faPauseCircle
    } else {
      return faPlayCircle
    }
  }

  return (
    <div>
      <button id="waveformAudioControl" className="button is-large" onClick={togglePlayPause}>
        <FontAwesomeIcon icon={stateButtonIcon()} />
      </button>

      <div ref={waveformRef}>
      </div>
    </div>
  )
}

export default WaveformPlayer

I think the only way I can access a WaveSurfer object outside of useEffect is by using useState however this doesn’t feel right to me as the WaveSurfer component shouldn’t completely re-render if some state changes in the component (e.g. user presses play/pause button). This leads me to think that my approach is not correct?

Edit: forgot to mention that waveform.playPause() fails because it is null when the play button is clicked.

Upvotes: 1

Views: 2560

Answers (2)

Stamatios Stamoulas
Stamatios Stamoulas

Reputation: 31

import React, { Fragment, useState, useEffect } from 'react'
import WaveSurfer from 'wavesurfer.js'
import audioFile from './../assets/sample-wave-file-5MB.wav'

const WaveSurferStatelessForm = () => {
  let [isPlaying, setIsPlaying] = useState(false)
  let [waveSurfer, setWaveSurfer] = useState(null)

  useEffect(() => {
    setWaveSurfer(WaveSurfer.create({
      container: '#waveform'
    }))
  }, [])

  useEffect(() => {
    if(waveSurfer) {
      waveSurfer.load(audioFile)
    }
  }, [waveSurfer])

  const togglePlayPause = () => {
    waveSurfer.playPause()
    setIsPlaying(!isPlaying)
  }

  return (
    <Fragment>
      <div id="waveform" ></div>
      <button onClick={() => togglePlayPause()}>{isPlaying ? '||' : '+'}</button>
    </Fragment>
  )
}

export default WaveSurferStatelessForm

Your waveform.playPause() fails after clicking the play button because you are updating state, playing, which will cause your component to re-render. Since your useEffect runs only on the first render, once the state changes your waveform will be set to null and useEffect will not re-instantiate it when the component re-renders. To solve this you need to either set the waveform to state, so it is always instantiated, or add the playing variable inside the [] so useEffect will re-instantiate the waveform each time the component re-renders. My solution above uses the former with a detailed explanation below.

Set the waveSurfer object after the first render, this way the <div id="waveform" ></div> will be mounted to the DOM before you create the waveSurfer object.

  useEffect(() => {
    setWaveSurfer(WaveSurfer.create({
      container: '#waveform'
    }))
  }, [])

Now that the waveSurfer object is set, we need to load the audio. This useEffect will run twice, but only load the audio once the waveSurfer object has been set, which will be the last time this useEffect is called. Prior to this, the waveSurfer object is null.

  useEffect(() => {
    if(waveSurfer) {
      waveSurfer.load(audioFile)
    }
  }, [waveSurfer])

As a note, useEffect will always run after the first render, then on any state change passed to the []. If there are no [] it will run after every render. So you don't need to check if the div exists if (waveformRef.current) it will always exist because useEffect is executing after the first render. In a statefull or class component this is similar to componentDidMount

Upvotes: 1

Tomek
Tomek

Reputation: 4839

The best I could come up with is declaring const waveform = React.useRef<WaveSurfer | null>(null) and using waveform.current in the rest of the file.

Upvotes: 0

Related Questions