useState() causes unexpected behaviour with audio object - reactjs

I'm trying to create a basic audio player and keeping track of the player state using the state hook.
The following code creates a component with the following behaviour:
First time the button is pressed audio plays
Every time the button is pressed the text toggles correctly
When state is playing, calling player.pause() does nothing
When state is not playing, audio continues and calling player.play() causes a second layer of audio to start on top
import React, {useState} from 'react'
function InlinePlayer ({audio}) {
const [playing, setPlaying] = useState(false)
const player = new Audio(audio.asset.url)
function togglePlay () {
playing ? player.pause() : player.play()
setPlaying(!playing)
}
return <>
<button onClick={() => togglePlay()}>
{playing ? 'Stop' : 'Play' }
</button>
</>
}
export default InlinePlayer
If I don't use the state hook at all I can stop and start the audio without issues.
One strange thing is that even if I call play() unconditionally and then call the state hook, subsequent calls to pause() also don't work anymore. It's as if calling setPlaying() destroys the connection to the player object. If I comment out the setPlaying line, it works.
function play () {
player.play()
setPlaying(true)
}
function pause () {
player.pause()
setPlaying(false)
}
I initially thought the problem was that the state was being set asynchronously so the conditional play was the culprit. What seems to be up here?

player.pause() and player.play() are like side effects. Use useEffect with proper cleanup function so you can toggle the play/pause:
import React, { useState, useEffect } from "react";
function InlinePlayer({ audio }) {
const [playing, setPlaying] = useState(false);
const player = new Audio(
"https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"
);
useEffect(() => {
playing ? player.play() : player.pause();
// This is cleanup of the effect
return () => player.pause();
}, [playing]);
// ^ Run the effect every time the `playing` is changed
function togglePlay() {
// Using the callback version of `setState` so you always
// toggle based on the latest state
setPlaying(s => !s);
}
return (
<>
<button onClick={() => togglePlay()}>{playing ? "Stop" : "Play"}</button>
</>
);
}

Instancing your "Audio" in state hook:
const [ sound ] = useState(new Audio('path_sound'));
return (
<div>
<button onClick={() => sound.paused = !sound.paused}>
{sound.paused ? "Play" : "Stop"}
</button>
</div>
);

Related

useState keeps the previous state

In my app I click Play button and audio should start playing, when it's over speech recognition should start listening, when it's over the message should be printed in console (it will be change to some other actions further).
My app also has a feature to stop recognition process if I click stop button, for that I use useState where false state changes to true.
But, I face an issue: when I try to stop listening by clicking Stop button, the state changes to true and it should stop here, but despite that I have if(isPlaying === false) condition, after true I get false in console as last action 🤷🏻‍♂️ Why it happens so, and how to fix it?
const [song] = useState(typeof Audio !== 'undefined' && new Audio())
const [isPlaying, setIsPlaying] = useState(false)
function playSong() {
setIsPlaying(!isPlaying)
if (listening) {
SpeechRecognition.stopListening()
}
if (isPlaying === false) {
song.src = 'https://raw.songToPlay.mp3'
song.play()
console.log('Song is playing: ' + isPlaying)
song.addEventListener('ended', () => {
console.log('Recognition is started: ' + isPlaying)
SpeechRecognition.startListening()
})
recognition.addEventListener('audioend', () => {
console.log('Recognition is finished: ' + isPlaying)
})
} else {
console.log('When stop is clicked: ' + isPlaying)
}
}
return (
<div>
<p>Microphone: {listening ? 'on' : 'off'}</p>
<button onClick={playSong}>{isPlaying ? 'Stop' : 'Play'}</button>
<p>{transcript}</p>
</div>
)
Output:
Logging the state from within playSong will log the state when the function was defined. This means that the value will be outdated, i.e. stale. This is because of a functional concept called closures. From Wikipedia “Unlike a plain function, a closure allows the function to access those captured variables through the closure's copies of their values or references, even when the function is invoked outside their scope.”
So how should we log the current state when it is changed? By using the useEffect hook. This function will be re-run whenever any of the dependencies change.
You can also return a 'cleanup' function to remove listeners, etc.
CodeSandbox
const [isPlaying, setIsPlaying] = useState(false);
useEffect(() => {
if (!isPlaying) {
console.log("Starting to play");
// add your event listener here to wait for song to end and start speech recognition
} else {
console.log("When stop is clicked: " + isPlaying);
// stop your speech recognition here
}
return () => {
// this is a cleanup function - you can use it to remove event listeners so you don't add them twice
};
}, [isPlaying]);
return (
<div>
<button onClick={() => setIsPlaying(!isPlaying)}>
{isPlaying ? "Stop" : "Play"}
</button>
</div>
);

Why are state changes in my custom hook not being read?

I'm building an audio player in React and I'm using a custom hook (useQueueHandler) to handle the logic of adding songs to the queue, skipping songs, etc.
I store the queue as state in useQueueHandler and pass down to my component a loadNextSong function which checks if there's a song in my queue and if so it changes the currentSong to the first song in queue. But the loadNextSong function can't see the songs I add to the queue.
I'm aware that a re-render is needed to see state changes, but I've tried forcing a state change in my loadNextSong function before checking the queue and I still can't see the updated queue state so I might not understand how or where I need to do a re-render.
Edit: Sorry, the code that actually runs the addSongToQueue function is in another component. But for simplicity I've added a button so you can see a static song being pushed as an example
AudioPlayer.js component
import useQueueHandler from "../hooks/useQueueHandler.js";
const AudioPlayer = () => {
const {userQueue, loadNextSong, addSongToQueue} = useQueueHandler()
return (
<div>
<div>
<button onClick={() => addSongToQueue({songTitle : "Angeles"})}
</div>
<div>
<button onClick={() => loadNextSong()}></button>
</div>
</div>
)
useQueueHandler custom hook
export default function useQueueHandler() {
const [currentSong, newCurrentSong] = useState({});
const [userQueue, setUserQueue] = useState([]);
const addSongToQueue = (song) => {
setUserQueue((prevState) => [...prevState, song]);
}
const loadNextSong = () => {
if (userQueue.length > 0) {
console.log(userQueue)
}
}
return {userQueue, loadNextSong, addSongToQueue}
}

Test document listener with React Testing Library

I'm attempting to test a React component similar to the following:
import React, { useState, useEffect, useRef } from "react";
export default function Tooltip({ children }) {
const [open, setOpen] = useState(false);
const wrapperRef = useRef(null);
const handleClickOutside = (event) => {
if (
open &&
wrapperRef.current &&
!wrapperRef.current.contains(event.target)
) {
setOpen(false);
}
};
useEffect(() => {
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
};
});
const className = `tooltip-wrapper${(open && " open") || ""}`;
return (
<span ref={wrapperRef} className={className}>
<button type="button" onClick={() => setOpen(!open)} />
<span>{children}</span>
<br />
<span>DEBUG: className is {className}</span>
</span>
);
}
Clicking on the tooltip button changes the state to open (changing the className), and clicking again outside of the component changes it to closed.
The component works (with appropriate styling), and all of the React Testing Library (with user-event) tests work except for clicking outside.
it("should close the tooltip on click outside", () => {
// Arrange
render(
<div>
<p>outside</p>
<Tooltip>content</Tooltip>
</div>
);
const button = screen.getByRole("button");
userEvent.click(button);
// Temporary assertion - passes
expect(button.parentElement).toHaveClass("open");
// Act
const outside = screen.getByText("outside");
// Gives should be wrapped into act(...) warning otherwise
act(() => {
userEvent.click(outside);
});
// Assert
expect(button.parentElement).not.toHaveClass("open"); // FAILS
});
I don't understand why I had to wrap the click event in act - that's generally not necessary with React Testing Library.
I also don't understand why the final assertion fails. The click handler is called twice, but open is true both times.
There are a bunch of articles about limitations of React synthetic events, but it's not clear to me how to put all of this together.
I finally got it working.
it("should close the tooltip on click outside", async () => {
// Arrange
render(
<div>
<p data-testid="outside">outside</p>
<Tooltip>content</Tooltip>
</div>
);
const button = screen.getByRole("button");
userEvent.click(button);
// Verify initial state
expect(button.parentElement).toHaveClass("open");
const outside = screen.getByTestId("outside");
// Act
userEvent.click(outside);
// Assert
await waitFor(() => expect(button.parentElement).not.toHaveClass("open"));
});
The key seems to be to be sure that all activity completes before the test ends.
Say a test triggers a click event that in turn sets state. Setting state typically causes a rerender, and your test will need to wait for that to occur. Normally you do that by waiting for the new state to be displayed.
In this particular case waitFor was appropriate.

Delay state change with async/await?

I'm making a custom error window that pops up in various situations. What I'm struggling with is getting the window to dissapear after 2 seconds.. Just a simple setTimeout to change the popup state to active:false is a bit unreliable because of the way the even loop works (i think?).
So I'm attempting an async/await way of doing it making sure it's always exactly 2 seconds. However the way I have done it below the timing still seems to be very weird, sometimes instant, sometimes 2 seconds.
How do I get my removeErrorMsg function to wait 2 seconds before setting the state?
///// App.js.js ////
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
export default class App extends Component {
state = {
errorPopup: {
active: false,
message: ''
}
}
removeErrorMsg = async() => {
await delay(2000);
this.setState({errorPopup: {active: false, message: ''}});
}
}
///// ErrorPopup.js ////
import React from 'react'
const ErrorPopup = ({ message, active, removeErrorMsg}) => {
if(active){
removeErrorMsg()
return (
<div className="error-popup">
<p>{message}</p>
</div>
)
} else return <div></div>
}
export default ErrorPopup
You must call the removeErrorMsg inside the ErrorPopup component within a useEffect function. Directly calling it will result in another delay being created which resets the state as soon as any other action in parent component tries to trigger a re-render leading to unexpected behaviours
const ErrorPopup = ({ message, active, removeErrorMsg}) => {
useEffect(() => {
if(active) {
removeErrorMsg()
}
}, [active])
if(active){
return (
<div className="error-popup">
<p>{message}</p>
</div>
)
} else return <div></div>
}
P.S. Although there is no gurantee that the setTimeout will execute immediately at 2sec, more or less it roughly execute around 2sec.

setInterval and React hooks produces unexpected results

I have the following component defined in my app scaffolded using create-react:
import React, { useState } from 'react';
const Play = props => {
const [currentSecond, setCurrentSecond] = useState(1);
let timer;
const setTimer = () => {
timer = setInterval(() => {
if (currentSecond < props.secondsPerRep) {
setCurrentSecond(() => currentSecond + 1);
}
}, 1000);
}
setTimer();
return (
<div>
<div>
<p>{currentSecond}</p>
</div>
</div>
);
}
export default Play;
And currentSecond is updated every second until it hits the props.secondsPerRep however if I try to start the setInterval from a click handler:
import React, { useState } from 'react';
const Play = props => {
const [currentSecond, setCurrentSecond] = useState(1);
let timer;
const setTimer = () => {
timer = setInterval(() => {
if (currentSecond < props.secondsPerRep) {
setCurrentSecond(() => currentSecond + 1);
}
}, 1000);
}
return (
<div>
<div>
<button onClick={setTimer}>Start</button>
<p>{currentSecond}</p>
</div>
</div>
);
}
export default Play;
Then currentSecond within the setInterval callback always returns to the initial value, i.e. 1.
Any help greeeeeeatly appreciated!
Your problem is this line setCurrentSecond(() => currentSecond + 1); because you are only calling setTimer once, your interval will always be closed over the initial state where currentSecond is 1.
Luckily, you can easily remedy this by accessing the actual current state via the args in the function you pass to setCurrentSecond like setCurrentSecond(actualCurrentSecond => actualCurrentSecond + 1)
Also, you want to be very careful arbitrarily defining intervals in the body of functional components like that because they won't be cleared properly, like if you were to click the button again, it would start another interval and not clear up the previous one.
I'd recommend checking out this blog post because it would answer any questions you have about intervals + hooks: https://overreacted.io/making-setinterval-declarative-with-react-hooks/
https://overreacted.io/making-setinterval-declarative-with-react-hooks/ is a great post to look at and learn more about what's going on. The React useState hook doesn't play nice with setInterval because it only gets the value of the hook in the first render, then keeps reusing that value rather than the updated value from future renders.
In that post, Dan Abramov gives an example custom hook to make intervals work in React that you could use. That would make your code look more like this. Note that we have to change how we trigger the timer to start with another state variable.
const Play = props => {
const [currentSecond, setCurrentSecond] = React.useState(1);
const [isRunning, setIsRunning] = React.useState(false);
useInterval(() => {
if (currentSecond < props.secondsPerRep) {
setCurrentSecond(currentSecond + 1);
}
}, isRunning ? 1000 : null);
return (
<div>
<div>
<button onClick={() => setIsRunning(true)}>Start</button>
<p>{currentSecond}</p>
</div>
</div>
);
}
I went ahead and put an example codepen together for your use case if you want to play around with it and see how it works.
https://codepen.io/BastionTheDev/pen/XWbvboX
That is because you're code is closing over the currentSecond value from the render before you clicked on the button. That is javascript does not know about re-renders and hooks. You do want to set this up slightly differently.
import React, { useState, useRef, useEffect } from 'react';
const Play = ({ secondsPerRep }) => {
const secondsPassed = useRef(1)
const [currentSecond, setCurrentSecond] = useState(1);
const [timerStarted, setTimerStarted] = useState(false)
useEffect(() => {
let timer;
if(timerStarted) {
timer = setInterval(() => {
if (secondsPassed.current < secondsPerRep) {
secondsPassed.current =+ 1
setCurrentSecond(secondsPassed.current)
}
}, 1000);
}
return () => void clearInterval(timer)
}, [timerStarted])
return (
<div>
<div>
<button onClick={() => setTimerStarted(!timerStarted)}>
{timerStarted ? Stop : Start}
</button>
<p>{currentSecond}</p>
</div>
</div>
);
}
export default Play;
Why do you need a ref and the state? If you would only have the state the cleanup method of the effect would run every time you update your state. Therefore, you don't want your state to influence your effect. You can achieve this by using the ref to count the seconds. Changes to the ref won't run the effect or clean it up.
However, you also need the state because you want your component to re-render once your condition is met. But since the updater methods for the state (i.e. setCurrentSecond) are constant they also don't influence the effect.
Last but not least I've decoupled setting up the interval from your counting logic. I've done this with an extra state that switches between true and false. So when you click your button the state switches to true, the effect is run and everything is set up. If you're components unmounts, or you stop the timer, or the secondsPerRep prop changes the old interval is cleared and a new one is set up.
Hope that helps!
Try that. The problem was that you're not using the state that is received by the setCurrentSecond function and the function setInterval don't see the state changing.
const Play = props => {
const [currentSecond, setCurrentSecond] = useState(1);
const [timer, setTimer] = useState();
const onClick = () => {
setTimer(setInterval(() => {
setCurrentSecond((state) => {
if (state < props.secondsPerRep) {
return state + 1;
}
return state;
});
}, 1000));
}
return (
<div>
<div>
<button onClick={onClick} disabled={timer}>Start</button>
<p>{currentSecond}</p>
</div>
</div>
);
}

Resources