Next.js making a timeout stop with a react state - javascript

Here is my component code (it is called on a page with <DetectPitch />);
import { useEffect, useState } from 'react';
export default function DetectPitch() {
const [detect, setDetect] = useState(false);
useEffect(() => {
document.getElementById("mute-button").addEventListener("click", () => setDetect(detect => !detect))
}, []);
useEffect(() => {
function update(random) {
if (detect != false) {
console.log("updating", random)
window.setTimeout(() => update(random), 100);
}
}
const audioContext = new window.AudioContext();
if (detect) {
audioContext.resume()
navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
update(Math.random());
});
} else {
audioContext.suspend()
}
}, [detect]);
return (
<button id="mute-button">{detect ? "Mute" : "Unmute"}</button>
)
}
This component renders a button that when pressed toggles between mute/unmute based on the value of a react state, detect. It also sets up listening to a users audio input device (I believe the audioContext is being set multiple times but thats a seperate issue right now). I would like the browser to stop listening to the user audio input device when the button mute is pressed and stop logging "updating" to the console.
With the code as it currently is the audioContext never stops and the message continues to log, this means that multiple presses of the button creates new timeouts that are looped infinitely at an increasing rate (this is demonstrated by the random number printed to the console, depending on how many times you have clicked the button the console displays multiple different random numbers).
I think this is happening because javascript is passing by value rather than reference and therefore the state never changes internally to the random() function. I have tried using a getter function for detect but that doesn't change anything and i've considered creating an object to hold the state but that makes the code more complex. I feel like there is a simpler options that i'm missing.
For now I would like to be able to get the timeout to stop printing so that I can continue debugging the functionality to use a single instance of audioContext.

The issue seems to be that update function which is called periodically does not have access to the latest detect state from useState() hook.
Some changes in functionality compared to the original code:
AudioContext has it's own state - one of 'suspended', 'running', 'closed' or 'interrupted'. So mirroring has to be setup to update detect React state so React can re-render every time AudioContext state changes.
click handler was changed according to React's event handling
setTimeout was replaced with setInterval for convenience
cleanup added closing AudioContext when component is unmounted
loading state displayed till user grants access to a microphone
For update function to get latest detect value I'm calling setDetect with a callback. This looks hacky to me but it works, maybe a class component implementation is better (see bellow).
import { useEffect, useState } from 'react';
export default function DetectPitch() {
const [detect, setDetect] = useState(false);
// audioContext created after first render, initially set to null
const [audioContext, setAudioContext] = useState(null);
function update(random) {
// access current value of 'detect' by calling 'setDetect'
setDetect(detect => {
if (detect) {
console.log("updating", random)
}
return detect;
});
}
useEffect(() => {
const context = new window.AudioContext();
// Update 'detect' every time audiocontext changes state
// true - if running
// false - if not running (suspended, closed or interrupted (if in Safari))
context.addEventListener('statechange', (event) => {
console.log('audioContext changed state to: ' + event.target.state);
const isRunning = event.target.state === 'running'
setDetect(isRunning);
});
setAudioContext(context);
// start calling 'update'
const rand = Math.random();
window.setInterval(() => update(rand), 1000);
// cleanup when component is unmounted
return () => {
if (audioContext) {
// close if audioContext was created
audioContext.close();
}
}
}, []); // create audioContext only once on initial render
function onClickHandler() {
if (detect) {
audioContext.suspend();
} else {
navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
audioContext.createMediaStreamSource(stream);
audioContext.resume();
});
}
}
// show Loading state while we create audioContext
if (!audioContext) {
return 'Loading...';
}
return (
<button onClick={onClickHandler}>
{detect ? "Mute" : "Unmute"}
</button>
)
}
Same implementation using class component:
import React from "react";
export default class DetectPitchClass extends React.Component {
constructor(props) {
// boilerplate
super(props);
this.update = this.update.bind(this);
this.onClickHandler = this.onClickHandler.bind(this);
// initial state
this.state = {
audioContext: null,
detect: false
};
}
componentDidMount() {
// initialised only once
const audioContext = new window.AudioContext();
// 'detect' mirrors state of audioContext
// true - if 'running'
// false - if not running (suspended, closed or interrupted)
// Safari changes state to interrupted if user switches to another tab
audioContext.addEventListener('statechange', (event) => {
console.log('audioContext changed state to: ' + event.target.state);
this.setState({ detect: event.target.state === 'running' });
});
this.setState({ audioContext });
// start calling 'update'
const rand = Math.random();
window.setInterval(() => this.update(rand), 1000);
}
componentWillUnmount() {
if (this.state.audioContext) {
// close if audioContext was created
this.state.audioContext.close();
}
}
// runs periodically, can always read 'detect' state
update(random) {
if (this.state.detect) {
console.log("updating", random)
}
}
onClickHandler() {
if (this.state.audioContext) {
if (this.state.detect) {
this.state.audioContext.suspend();
} else {
navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
this.state.audioContext.createMediaStreamSource(stream);
this.state.audioContext.resume();
});
}
}
}
render() {
// show Loading state while we create audioContext
if (!this.state.audioContext) {
return 'Loading...';
}
return (
<button onClick={this.onClickHandler}>
{this.state.detect ? "Mute" : "Unmute"}
</button>
)
}
}

For completion sake, after doing more research. I have discovered that these sorts of problems are that Redux and advanced React Hooks (useContext and useReducer) set out to solve.

Related

listen to deeply nested react state

I am currently working on a music player in React.
So far I have a Context Provider with a music element stored with the useState hook.
const [currentSong, setCurrentSong] = useState(null);
useEffect(() => {
fetchSong();
}, []);
const fetchSong = () => {
const songAudio = new Audio(`localhost/song/13/audio`)
songAudio.onloadeddata = () => {
songAudio.play();
setCurrentSong(songAudio);
}
}
After that the currentSong Object looks something like this
<audio preload="auto" src="http://localhost/song/13/audio">
{...}
duration: 239.081
currentTime: 113.053
​{...}
<prototype>: HTMLAudioElementPrototype { … }
Because the song is playing the currentTime gets updated automatically.
My question is if it is possible to trigger a rerender every time currentTime changes so that I can update a span element with that number.
The span is in a seperate file and consumes the Context Provider which provides the currentSong object.
const { currentSong, {...} } = useMusicContext();
{...}
return (
<span className='...'>
{currentSong? currentSong.currentTime: "0:00"}
</span>
)
The problem is that the component does not know that the currentTime value changed and only updates the text if a rerender is triggered by something else.
Add an event listener to the audio element for timeupdate events and use those to update your state (or whatever).
Here's a quick demo implementation. Source included below for easier reference.
// Audio component to handle attaching the listener
import { useEffect, useRef } from "react";
export function Audio({ onTimeUpdate, ...props }) {
const audioRef = useRef();
useEffect(() => {
const { current } = audioRef;
current?.addEventListener("timeupdate", onTimeUpdate);
return () => current?.removeEventListener("timeupdate", onTimeUpdate);
}, [audioRef, onTimeUpdate]);
return (
<audio ref={audioRef} {...props} />
);
}
export default function App() {
const [time, setTime] = useState();
const onTimeUpdate = (e) => {
setTime(e.target.currentTime);
};
return (
<div className="App">
<Audio onTimeUpdate={onTimeUpdate} controls src="./audio-sample.mp3" />
<div>{time}</div>
</div>
);
}
Tough to exactly say what to do here - would need more info/code, but I do believe that passing down currentTime as a prop would work.
If that is not possible, or you don't want to keep passing down props, you may want to look into the react hook called useContext.
Alternatively, perhaps you could use useEffect to trigger re-renders in the component you want to update. Not exactly sure how you would trigger this re-render/what you would put in the dependency array without more info.

React - Race Condition in multiple useEffect's cleanup function

I have a component that
Initiate a websocket ref on mount (endpoint prop doesn't change)
When component comes into screen view, it calls the subscribe on wsRef
When component goes out of screen view, it calls the unsubscribe on wsRef
The problem is on unmount the 2nd cleanup function fails because we already have closed the websocket by the 1st cleanup function.
So we are unable to unsubscribe as the websocket is not in OPEN state.
How to control the order of cleanup effects here?
function Comp({endpoint, isVisible, id}) {
const wsRef = useRef(null);
useEffect(() => {
wsRef.current = new WebsocketClass(endpoint);
return () => { // [1] cleanup no 1
wsRef.current.close();
wsRef.current = null;
}
}, [endpoint]);
useEffect(() => {
if (isVisible) {
wsRef.current.subscribe(id);
}
return () => {
if (isVisible) {
wsRef.current.unsubscribe(id); // [2] fails, this cleanup runs after [1]
}
}
}, [isVisible, id]);
}
If I re-order the two useEffect in my function, the problem seem to go away, but is it really a permanent solution here?
Also, I can't close the websocket in the unsubscribe cleanup because the component can stay mounted while it goes out of screen and comes back again as user scrolls back and forth.

How to open Modal based on a function that changes the useState boolean in React?

I currently have the following useState and function:
const [displayTraits, setDisplayTraits] = useState(false);
const feelingsFilled = () => {
const keysToCheck = ["anxiety", "mood", "cognition"];
function validate(obj) {
try {
if (
Object.keys(obj)
.filter((key) => keysToCheck.includes(key))
.every((key) => obj[key].counter > 0)
) {
setDisplayTraits(true);
} else {
setDisplayTraits(false);
}
} catch (err) {
console.log("error");
}
}
validate(daily);
};
feelingsFilled();
which I then try to hook up with a modal so that when my function feelingsFilled() return true and changes the displayTraits to true, then it will open.
<Modal isVisible={displayTraits} />
I am trying to run this but get the following error
Error: Too many re-renders. React limits the number of renders to prevent an infinite loop.
I would need to make some assumptions on where the daily data comes from. If it is a changing prop or some other state, you can compute stuff based on that.
Using some state:
useEffect(() => {
//computeYourStuffAndSetState
const result = validate(daily);
setDisplayTraits(result);
}, [daily]);
// The array param is a change-listener
Then you can bind your <Modal visible directly to the state variable, instead of a function.
Another example is to not use state at all and compute it everytime daily changes.
const showModal = useMemo(() => {
return validate(daily);
}, [daily]);
useMemo and useEffect is a part of the built in react hooks.
Or you can just do something like:
const showModal = validate(daily);
This will also work, but will be less performant as it will recompute on every render

Change in localstorage not triggering event listener

I have React component. Initially I set some localStorage in UseEffect. Moreover I add event listener. After clicking on text it changes the localStorage value but event listener does not triggering, why?
import React, { useEffect } from "react";
export default function App() {
useEffect(() => {
window.localStorage.setItem("item 1", 'val 1');
window.addEventListener('storage', () => {
alert('localstorage changed!')
})
}, []);
const getData = () => {
localStorage.setItem("item", "val chamged");
};
return (
<div className="App">
<h1 onClick={getData}>Change localstorage value</h1>
</div>
);
}
https://codesandbox.io/s/naughty-engelbart-90tkw
There are two things wrong.
change onClick={getData()} onClick={getData}
From the doc(https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event). The storage event of the Window interface fires when a storage area (localStorage or sessionStorage) has been modified in the context of another document. Note the last sentence that says it won't be fired in the same document. You can see that if you open https://codesandbox.io/s/spring-browser-89con in 2 tabs in same browser, the alert will start coming.
To update the localStorage in the same window, you need to dispatch a storage event.
I changed some of the values from the original question to update the same cookie that is initially set. Don't forget that event listeners need to be removed when the component unmounts and localStorage items should have a same site and secure attribute!
import React, { useEffect } from "react";
export default function App() {
//Set localStorage item when the component mounts and add storage event listener
useEffect(() => {
const alertMessage = () => {
alert('localStorage changed!');
}
window.localStorage.setItem("item", 'val 1', { sameSite: "strict", secure: true });
window.addEventListener('storage', alertMessage);
//Remove the event listener when the component unmounts
return () => {
window.removeEventListener("storage", alertMessage);
}
}, []);
//Update the localStorage onClick
const updateData = () => {
localStorage.setItem("item", "val changed", { sameSite: "strict", secure: true });
window.dispatchEvent(new Event("storage")); //This is the important part
};
return (
<div className="App">
<h1 onClick={updateData}>Change localStorage value</h1>
</div>
);
}
The Storage event is triggered when there is a change in the window's storage area.
Note: The storage event is only triggered when a window other than
itself makes the changes.
You can see more details and demo: storage Event
The storage event handler will only affect other windows. Whenever something changes in one window inside localStorage all the other windows are notified about it and if any action needs to be taken it can be achieved by a handler function listening to the storage event.
Try with this onclick handler
import React, { useEffect } from "react";
export default function App() {
useEffect(() => {
window.localStorage.setItem("item 1", 'val 1');
window.addEventListener('storage', () => {
alert('localstorage changed!')
})
}, []);
const getData = () => {
localStorage.setItem("item", "val chamged");
};
return (
<div className="App">
<h1 onClick={()=>getData()}>Change localstorage value</h1>
</div>
);
}
I will suggest a solution. It might be an ugly solution, but it is the only solution that I have found to achieve it from the same window. (for some reasons I need to get the change :) )
I've used the MutationObserver class, for example to get any changes on the dark mode value:
const setTheme = (value) => {
let htmlClasses = document.querySelector('html').classList
if (value == true) {
htmlClasses.add('dark');
} else {
htmlClasses.remove('dark');
}
window.localStorage.setItem('dark', value)
}
Here I am adding a class each time the toggle button has been activated, then I configure an observer on the html classes like this:
var observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
console.log(mutation);
if (mutation.attributeName == "class") {
// Do what you want here
}
});
});
var config = {
attributes: true
};
observer.observe(document.querySelector('html'), config);
You can see also an other example (more useful for you) that I've found on github here

componentDidMount not being called after goBack navigation call

I've set up a StackNavigator which will fire a redux action to fetch data on componentDidMount, after navigating to another screen and going back to the previous screen, componentDidMount is no longer firing and I'm presented with a white screen. I've tried to reset the StackNavigator using StackActions.reset but that just leads to my other screens not mounting as well and in turn presenting an empty screen. Is there a way to force componentDidMount after this.props.navigation.goBack()?
Go Back Function
_goBack = () => {
this.props.navigation.goBack();
};
Navigation function to new screen
_showQuest = (questId, questTitle ) => {
this.props.navigation.navigate("Quest", {
questId,
questTitle,
});
};
Edit :
Hi , I'm still stuck on this matter and was wondering if the SO Community has a solution to force componentDidMount to be called after Navigation.goBack() is called or rather how to Unmount the component after this.props.navigation.navigate is called
The components are not removed when the navigation changes, so componentDidMount will only be called the first time it is rendered.
You could use this alternative approach instead:
class MyComponent extends React.Component {
state = {
isFocused: false
};
componentDidMount() {
this.subs = [
this.props.navigation.addListener("didFocus", () => this.setState({ isFocused: true })),
this.props.navigation.addListener("willBlur", () => this.setState({ isFocused: false }))
];
}
componentWillUnmount() {
this.subs.forEach(sub => sub.remove());
}
render() {
if (!this.state.isFocused) {
return null;
}
// ...
}
}
The didFocus event listener didn't work for me.
I found a different listener called focus which finally worked!
componentDidMount() {
const { navigation } = this.props;
this.focusListener = navigation.addListener('focus', () => {
// call your refresh method here
});
}
componentWillUnmount() {
// Remove the event listener
if (this.focusListener != null && this.focusListener.remove) {
this.focusListener.remove();
}
}

Categories