useState keeps the previous state - reactjs

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>
);

Related

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.

React useCallback function doesn't get current context value

I am trying to implement a use case with React Hooks and React Context API with mouse events.
I want to add mousemove event for a container. If user moves over an object (rectangle), a dispatch action is called and context value is updated. I want to achieve that the action is not dispatched repeatedly by checking context value before dispatching. The issue is that function doesn't get current context value.
This the event function useMouseEvents.js
import * as React from "react";
import { DragToCreateContext, actionTypes } from "./reducer";
export function useMouseEvents(elRef) {
const { dragToCreate, dispatchDragToCreate } = React.useContext(
DragToCreateContext
);
console.log("out of callback", dragToCreate);
const handleMouseMove = React.useCallback(
(evt) => {
if (evt.target.tagName === "P") {
console.log("inside callback", dragToCreate);
}
if (evt.target.tagName === "P" && dragToCreate.sourceNodeId === null) {
console.log("dispatch");
dispatchDragToCreate({
type: actionTypes.ACTIVATE,
sourceNodeId: 1
});
}
},
[dragToCreate]
);
React.useEffect(() => {
const el = elRef?.current;
if (el) {
el.addEventListener("mousemove", handleMouseMove);
return () => {
el.addEventListener("mousemove", handleMouseMove);
};
}
}, [elRef, handleMouseMove]);
}
codesandbox.io
If you hover over rectangle, you will see in console log:
inside callback {sourceNodeId: null}
dispatch
out of callback {sourceNodeId: 1}
inside callback {sourceNodeId: null}
dispatch
out of callback {sourceNodeId: 1}
inside callback {sourceNodeId: 1}
inside callback {sourceNodeId: null}
but it should be
inside callback {sourceNodeId: null}
dispatch
out of callback {sourceNodeId: 1}
inside callback {sourceNodeId: 1}
The behaviour that you see is because your listeners on mouseMove are removed and added whenever your context value changes. Also since your listener is recreated in useEffect it might so happen that before a new listener is attached, an old one executes and you get an old value from the closure.
To solve such scenarios, you can make use of a ref to keep track of updated context values and use that inside your listener callback. This way you will be able to avoid addition and removal of mouse event listener
import * as React from "react";
import { DragToCreateContext, actionTypes } from "./reducer";
export function useMouseEvents(elRef) {
const { dragToCreate, dispatchDragToCreate } = React.useContext(
DragToCreateContext
);
console.log("out of callback", dragToCreate);
const dragToCreateRef = React.useRef(dragToCreate);
React.useEffect(() => {
dragToCreateRef.current = dragToCreate;
}, [dragToCreate]);
const handleMouseMove = React.useCallback((evt) => {
if (evt.target.tagName === "P") {
console.log("inside callback", dragToCreateRef.current);
}
if (
evt.target.tagName === "P" &&
dragToCreateRef.current.sourceNodeId === null
) {
console.log("dispatch");
dispatchDragToCreate({
type: actionTypes.ACTIVATE,
sourceNodeId: 1
});
}
}, []);
React.useEffect(() => {
const el = elRef?.current;
if (el) {
el.addEventListener("mousemove", handleMouseMove);
return () => {
el.addEventListener("mousemove", handleMouseMove);
};
}
}, [elRef, handleMouseMove]);
}
Working codesandbox demo
I can't really find the answers I was hoping for but here's a couple things for you and maybe anyone else wanting to write an answer:
The inside callback {sourceNodeId: null} tells me that something is causing the Context to reset to it's initial values and the way you used your Provider is pretty atypical to what I usually see (changing this didn't really seem to fix anything though).
I thought maybe the useContext inside of useMouseEvents is getting just the default context, but I tried moving things around to guarentee that wasn't the case but that didn't seem to work. (Someone else might want to retry this?)
Edit: Removed this suggestion
Kinda unrelated to the issue, but you're going to want to change your useEffect too:
React.useEffect(() => {
const el = elRef?.current;
if (el) {
el.addEventListener("mousemove", handleMouseMove);
return () => {
el.removeEventListener("mousemove", handleMouseMove);
};
}

useState() causes unexpected behaviour with audio object

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>
);

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.

Managing Button State and Resultant Processing with React Hooks

I've got some toggles that can be turned on/off. They get on/off state from a parent functional component. When a user toggles the state, I need to update the state in the parent and run a function.
That function uses the state of all the toggles to filter a list of items in state, which then changes the rendered drawing in a graph visualization component.
Currently, they toggle just fine, but the render gets out of sync with the state of the buttons, because the processing function ends up reading in old state.
I tried using useEffect(), but because the function has a lot of dependencies it causes a loop.
I tried coupling useRef() with useState() in a custom hook to read out the current state of at least the newest filter group that was set, but no luck there either.
Any suggestions on how I could restructure my code in a better way altogether, or a potential solution to this current problem?
Gross function that does the filtering:
function filterItems(threshold, items = {}) {
const { values } = kCoreResult;
const { coloredItems } = rgRef.current;
let itemsForUse;
let filteredItems;
if (Object.entries(items).length === 0 && items.constructor === Object) {
itemsForUse = baseItemsRef.current;
} else {
itemsForUse = items;
}
const isWithinThreshold = id => has(values, id) && values[id] >= threshold;
// filter for nodes meeting the kCoreValue criterion plus all links
filteredItems = pickBy(
itemsForUse,
(item, id) => !isNode(item) || isWithinThreshold(id)
);
filteredItems = pickBy(
filteredItems,
item =>
!has(item, 'data.icon_type') || !filterRef.current[item.data.icon_type]
);
setRg(rg => {
rg.filteredItems = leftMerge(filteredItems, coloredItems);
return {
...rg,
};
});
setMenuData(menuData => {
menuData.threshold = threshold;
return {
...menuData,
};
});
}
Function that calls it after button is pressed that also updates button state (button state is passed down from the filter object):
function changeCheckBox(id, checked) {
setFilter(filter => {
filter[id] = !checked;
return {
...filter,
};
});
filterItems(menuData.threshold);
}
It seems calling your filterItems function in the handler is causing the stale state bug, the state update hasn't been reconciled yet. Separate out your functions that update state and "listen" for updates to state to run the filter function.
Here's a demo that should help see the pattern:
export default function App() {
const [filters, setFilters] = useState(filterOptions);
const onChangeHandler = e => {
setFilters({ ...filters, [e.target.name]: e.target.checked });
};
const filterItems = (threshold, items = {}) => {
console.log("Gross function that does the filtering");
console.log("threshold", threshold);
console.log("items", items);
};
useEffect(() => {
filterItems(42, filters);
}, [filters]);
return (
<div className="App">
<h1>Hello CodeSandbox</h1>
<h2>Start editing to see some magic happen!</h2>
{Object.entries(filters).map(([filter, checked]) => {
return (
<Fragment key={filter}>
<label htmlFor={filter}>{filter}</label>
<input
id={filter}
name={filter}
type="checkbox"
checked={checked}
onChange={onChangeHandler}
/>
</Fragment>
);
})}
</div>
);
}
This works by de-coupling state updates from state side-effects. The handler updates the filters state by always returning a new object with next filter values, and the effect hook triggers on value changes to filters.

Resources