Wait for DOM element then react - Reactjs - javascript

Hi I have a class component as shown below:
class SomeComponent extends Component {
componentDidMount = () => {
const divElement = document.getElementbyId('id'); // this element could take a few seconds to load
if (props.something1 && props.something2) {
..do something with divElement's width
}
}
render() {
return ....
}
}
I want to wait until divElement is loaded, or trigger an event when divElement is loaded so I can do my calculation later, tried adding setTimeout which did not work

Two answers for you:
Use a ref (if your component renders the element)
If the element is rendered by your component, use a ref.
Use a MutationObserver (if the element is outside React)
If the element is completely outside the React part of your page, I'd look for it with getElementById as you are, and if you don't find it, use a MutationObserver to wait for it to be added. Don't forget to remove the mutation observer in componentWillUnmount.
That would look something like this:
componentDidMount = () => {
const divElement = document.getElementbyId('id');
if (divElement) {
this.doStuffWith(divElement);
} else {
this.observer = new MutationObserver(() => {
const divElement = document.getElementbyId('id');
if (divElement) {
this.removeObserver();
this.doStuffWith(divElement);
}
});
this.observer.observe(document, {subtree: true, childList: true});
}
}
componentWillUnmount = () => {
this.removeObserver();
}
removeObserver = () => {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
}
(You may have to tweak that, it's off-the-cuff; see the MutationObserver documentation for details.)

This is a dumb solution but it gets its jobs done:
const getElementByIdAsync = id => new Promise(resolve => {
const getElement = () => {
const element = document.getElementById(id);
if(element) {
resolve(element);
} else {
requestAnimationFrame(getElement);
}
};
getElement();
});
To use it:
componentDidMount = async () => {
const divElement = await getElementByIdAsync('id');
if (props.something1 && props.something2) {
// ..do something with divElement's width
}
}

You need to use the componentDidUpdate hook instead of componentDidMount hook. And better to use ref rather than getting div element by it's id:
componentDidUpdate() {
if (props.something1 && props.something2) {
// use divElementRef to interact with
}
}

The answer of #hao-wu is great. Just if anyone wonders how to use it with hooks here is my snippet.
const Editor = () => {
const [editor, setEditor] = useState<SimpleMDE | null>(null);
useEffect(() => {
(async () => {
const initialOptions = {
element: await getElementByIdAsync(id),
initialValue: currentValueRef.current
};
setEditor(
new SimpleMDE({
element: await getElementByIdAsync(id),
initialValue: currentValueRef.current
})
);
})();
}, [id]);
// Other effects that are looking for `editor` instance
return <textarea id={id} />;
};
Otherwise, the constructor of SimpleMDE cannot find an element and everything is broken :)
I guess you can adjust it to your use-case quite easily.
Most of the time useRef just works, but not in this scenario.

Here's my hook version to #hao-wu's answer
const useGetElementAsync = (query) => {
const [element, setElement] = useState(null);
useEffect(() => {
(async () => {
let element = await new Promise((resolve) => {
function getElement() {
const element = document.querySelector(query);
if (element) {
resolve(element);
} else {
console.count();
// Set timeout isn't a must but it
// decreases number of recursions
setTimeout(() => {
requestAnimationFrame(getElement);
}, 100);
}
};
getElement();
});
setElement(element);
})();
}, [query]);
return element;
};
To use it:
export default function App() {
const [isHidden, setIsHidden] = useState(true);
const element = useGetElementAsync(".myElement");
// This is to simulate element loading at a later time
useEffect(() => {
setTimeout(() => {
setIsHidden(false);
}, 1000);
}, []);
return (
<div className="App">
<h1>Hello CodeSandbox</h1>
{!isHidden && (
<h2 className="myElement">My tag name is {element?.tagName}</h2>
)}
</div>
);
}
Here's the codesandbox example

You can do something like;
componentDidMount() {
// Triggering load of some element
document.querySelector("#id").onload = function() {
// Write your code logic here
// code here ..
}
}
Reference https://developer.mozilla.org/en-US/docs/Web/API/GlobalEventHandlers/onload

Related

How to clear setTimeout when the page is changed in reactjs - nextjs

I created a slideshow in the Nextjs project, But I have a bug. When the user clicks on a link and the page has changed I get an Unhandled Runtime Error and I know it because of the setTimeout function it calls a function and tries to style an element that does not exist on the new page.
How can I clear the setTimeout function after the user click the links?
Error screenshot:
My component code:
import { useEffect, useState } from "react";
import SlideContent from "./slide-content";
import SlideDots from "./slide-dots";
import SlideItem from "./slide-item";
const Slide = (props) => {
const { slides } = props;
const [slideLength, setSlideLength] = useState(slides ? slides.length : 0);
const [slideCounter, setSlideCounter] = useState(1);
const handleSlideShow = () => {
if (slideCounter < slideLength) {
document.querySelector(
`.slide-content:nth-of-type(${slideCounter})`
).style.left = "100%";
const setSlide = slideCounter + 1;
setSlideCounter(setSlide);
setTimeout(() => {
document.querySelector(
`.slide-content:nth-of-type(${setSlide})`
).style.left = 0;
}, 250);
} else {
document.querySelector(
`.slide-content:nth-of-type(${slideCounter})`
).style.left = "100%";
setSlideCounter(1);
setTimeout(() => {
document.querySelector(`.slide-content:nth-of-type(1)`).style.left = 0;
}, 250);
}
};
useEffect(() => {
if (slideLength > 0) {
setTimeout(() => {
handleSlideShow();
}, 5000);
}
}, [slideCounter, setSlideCounter]);
return (
<>
<div className="slide-button-arrow slide-next">
<span className="carousel-control-prev-icon"></span>
</div>
<div className="slide">
{slides.map((slide) => (
<SlideContent key={`slide-${slide.id}`}>
<SlideItem img={slide.img} title={slide.title} />
</SlideContent>
))}
<SlideDots activeDot={slideCounter} totalDots={slides} />
</div>
<div className="slide-button-arrow slide-prev">
<span className="carousel-control-next-icon"></span>
</div>
</>
);
};
export default Slide;
I use my slideshow component inside the home page file.
useEffect(() => {
let timer;
if (slideLength > 0) {
timer=setTimeout(() => {
handleSlideShow();
}, 5000);
}
return () => {
clearTimeout(timer);
};
}, [slideCounter, setSlideCounter]);
you should remove your timeout function when the component unmounts.
(if you're using old syntax there is componentWillUnmount() function)
when you are using hooks you can return your useEffect so it will cause the unmount function.
in your case it will be something like this:
useEffect(() => {
//define a temp for your timeout to clear it later
let myTimeout;
if (slideLength > 0) {
//assign timeout function to the variable
myTimeout = setTimeout(() => {
handleSlideShow();
}, 5000);
}
// this triggers when the component unmounts or gets re-rendered.
// you can clear the timeout here.
return () => {
clearTimeout(myTimeout);
}
}, [slideCounter, setSlideCounter]);
you should always remove your timeouts because you don't want memory leaks and performance issues. it might not give you errors but clear them all.
there is an old post i guess you can read here

How to remove event listener inside useCallback hook

I want to create a generic react hook that will add a scroll event to the element and return a boolean indicating that the user has scrolled to the top of the element.
Now, the problem is this element might not be visible right away. Hence I'm not able to use useEffect. As I understand in that situation it is advised to use useCallback
So I did, and it works:
function useHasScrolled() {
const [hasScrolled, setHasScrolled] = useState(false);
const ref = useRef(null);
const setRef = useCallback((element) => {
const handleScroll = (e) => {
setHasScrolled(e.target.scrollTop !== 0);
};
if (element) {
element.addEventListener("scroll", handleScroll);
}
ref.current = element;
}, []);
return {
hasScrolled,
scrollingElementRef: setRef
};
}
I can use my hook like this:
const { hasScrolled, scrollingElementRef } = useHasScrolled();
....
return <div ref={scrollingElementRef}>....
However, the problem is, I don't know how to remove the event listener. With the useEffect hook, it's pretty straightforward - you just return the cleanup function.
Here's the codesandbox, if you want to check the implementation: https://codesandbox.io/s/pedantic-dhawan-83fdw3
Expected behavior - when node is removed from DOM - event listeners will be also removed and collected by GC.
But
Codesandbox example is a bit tricky, React treats
<div>Loading...</div>
and
<div className="scrollingDiv" ref={scrollingElementRef}>
<h1>Hello, I've finally loaded!</h1>
<Lorem />
</div>
as a same div, same object, just with different props (className and children), so when div.scrollingDiv is replaced by conditional rendering to div(loading) - event listeners are still there and accumulating.
This behavior can be fixed as is by using keys.
{loading ? (
<div key="div1">Loading...</div>
) : (
<div key="div2" className="scrollingDiv" ref={scrollingElementRef}>
<h1>Hello, I've finally loaded!</h1>
<Lorem />
</div>
)}
In that way event listeners will be removed as expected.
Another solution is to add 1 more useRef and useEffect to the custom hook to store and execute actual unsubscribe function:
function useHasScrolled() {
const [hasScrolled, setHasScrolled] = useState(false);
const ref = useRef(null);
const unsubscribeRef = useRef(null);
const setRef = useCallback((element) => {
const eventName = "scroll";
const handleScroll = (e) => {
setHasScrolled(e.target.scrollTop !== 0);
};
if (unsubscribeRef.current) {
unsubscribeRef.current();
unsubscribeRef.current = null;
}
if (element) {
element.addEventListener(eventName, handleScroll);
unsubscribeRef.current = () => {
console.log("removeEventListener called on: ", element);
element.removeEventListener(eventName, handleScroll);
};
ref.current = element;
} else {
unsubscribeRef.current = null;
ref.current = null;
}
}, []);
useEffect(() => {
return () => {
if (unsubscribeRef.current) {
unsubscribeRef.current();
unsubscribeRef.current = null;
}
};
}, []);
return {
hasScrolled,
scrollingElementRef: setRef
};
}
That code will work without adding key.
Utility code for Chrome dev console to count scroll listeners:
Array.from(document.querySelectorAll('*'))
.reduce(function(pre, dom){
var clks = getEventListeners(dom).scroll;
pre += clks ? clks.length || 0 : 0;
return pre
}, 0)
Updated codesandbox: https://codesandbox.io/s/angry-einstein-6fb1u4?file=/src/App.js

Value of variable outside of useEffect hook has old data

What the code does: It's performing a DOM search based on what's typed in an input (it's searching elements by text). All this is happening in a React component.
import { useEffect, useReducer } from "react";
let elements: any[] = [];
const App = () => {
const initialState = { keyEvent: {}, value: "Initial state" };
const [state, updateState] = useReducer(
(state: any, updates: any) => ({ ...state, ...updates }),
initialState
);
function handleInputChange(event: any) {
updateState({ value: event.target.value });
}
function isCommand(event: KeyboardEvent) {
return event.ctrlKey;
}
function handleDocumentKeyDown(event: any) {
if (isCommand(event)) {
updateState({ keyEvent: event });
}
}
useEffect(() => {
document.addEventListener("keydown", handleDocumentKeyDown);
return () => {
document.removeEventListener("keydown", handleDocumentKeyDown);
};
}, []);
useEffect(() => {
const selectors = "button";
const pattern = new RegExp(state.value === "" ? "^$" : state.value);
elements = Array.from(document.querySelectorAll(selectors)).filter(
(element) => {
if (element.childNodes) {
const nodeWithText = Array.from(element.childNodes).find(
(childNode) => childNode.nodeType === Node.TEXT_NODE
);
if (nodeWithText) {
// The delay won't happenn if you comment out this conditional statement:
if (nodeWithText.textContent?.match(pattern)) {
return element;
}
}
}
}
);
console.log('elements 1:', elements)
}, [state]);
console.log('elemets 2:', elements)
return (
<div>
<input
id="input"
type="text"
onChange={handleInputChange}
value={state.value}
/>
<div id="count">{elements.length}</div>
<button>a</button>
<button>b</button>
<button>c</button>
</div>
);
};
export default App;
The problem: The value of elements outside of useEffect is the old data. For example, if you type a in the input, console.log('elements 1:', elements) will log 1, and console.log('elements 2:', elements) will log 0. Note: there are 3 buttons, and one of them has the text a.
The strange thing is that the problem doesn't happen if you comment out this if-statement:
// The delay won't happenn if you comment out this conditional statement:
if (nodeWithText.textContent?.match(pattern)) {
return element;
}
In this case, if you type anything (since the pattern matching has been commented out), console.log('elements 1:', elements) and console.log('elements 2:', elements) will log 3. Note: there are 3 buttons.
Question: What could be the problem, and how to fix it? I want to render the current length of elements.
Live code:
It's happening because of the elements variable is not a state, so it's not reactive.
Create a state for the elements:
const [elements, setElements] = useState<HTMLButtonElement[]>([])
And use this state to handle the elements.
import { useEffect, useReducer, useState } from "react";
const App = () => {
const initialState = { keyEvent: {}, value: "Initial state" };
const [state, updateState] = useReducer(
(state: any, updates: any) => ({ ...state, ...updates }),
initialState
);
const [elements, setElements] = useState<HTMLButtonElement[]>([])
function handleInputChange(event: any) {
updateState({ value: event.target.value });
}
function isCommand(event: KeyboardEvent) {
return event.ctrlKey;
}
function handleDocumentKeyDown(event: any) {
if (isCommand(event)) {
updateState({ keyEvent: event });
}
}
useEffect(() => {
document.addEventListener("keydown", handleDocumentKeyDown);
return () => {
document.removeEventListener("keydown", handleDocumentKeyDown);
};
}, []);
useEffect(() => {
const selectors = "button";
const pattern = new RegExp(state.value === "" ? "^$" : state.value);
let newElements = Array.from(document.querySelectorAll(selectors)).filter(
(element) => {
if (element.childNodes) {
const nodeWithText = Array.from(element.childNodes).find(
(childNode) => childNode.nodeType === Node.TEXT_NODE
);
if (nodeWithText) {
// The delay won't happenn if you comment out this conditional statement:
if (nodeWithText.textContent?.match(pattern)) {
return element;
}
}
}
}
);
setElements(newElements)
console.log("elements 1:", elements?.length);
}, [state]);
console.log("elemets 2:", elements?.length);
return (
<div>
<input
id="input"
type="text"
onChange={handleInputChange}
value={state.value}
/>
<div id="count">{elements?.length}</div>
<button>a</button>
<button>b</button>
<button>c</button>
</div>
);
};
export default App;
Your useEffect() runs after your component has rendendered. So the sequence is:
You type something into input, that triggers handleInputChange
handleInputChange then updates your state using updateState()
The state update causes a rerender, so App is called App()
console.log('elemets 2:', elements.length) runs and logs elements as 0 as it's still empty
App returns the new JSX
Your useEffect() callback runs, updating elements
Notice how we're only updating the elements after you've rerendered and App has been called.
The state of your React app should be used to describe your UI in React. Since elements isn't React state, it has a chance of becoming out of sync with the UI (as you've seen), whereas using state doesn't have this issue as state updates always trigger a UI update. Consider making elements part of your state. If it needs to be accessible throughout your entire App, you can pass it down as props to children components, or use context to make it accessible throughout all your components.
With that being said, I would make the following updates:
Add elements to your state
Remove your useEffect() with the dependency of [state]. If we were to update the elements state within this effect, then that would trigger another rerender directly after the one we just did for the state update. This isn't efficient, and instead, we can tie the update directly to your event handler. See You Might Not Need an Effect for more details and dealing with other types of scenarios:
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/#babel/standalone/babel.min.js"></script>
<script type="text/babel">
const { useEffect, useReducer} = React;
const App = () => {
const initialState = {keyEvent: {}, value: "Initial state", elements: []};
const [state, updateState] = useReducer(
(state: any, updates: any) => ({ ...state, ...updates}),
initialState
);
function searchDOM(value) {
const selectors = "button";
const pattern = new RegExp(value === "" ? "^$" : value);
return Array.from(document.querySelectorAll(selectors)).filter(
(element) => {
if (element.childNodes) {
const nodeWithText = Array.from(element.childNodes).find(
(childNode) => childNode.nodeType === Node.TEXT_NODE
);
return nodeWithText?.textContent?.match(pattern);
}
return false;
}
);
}
function handleInputChange(event) {
updateState({
value: event.target.value,
elements: searchDOM(event.target.value)
});
}
function isCommand(event) {
return event.ctrlKey;
}
function handleDocumentKeyDown(event) {
if (isCommand(event)) {
updateState({
keyEvent: event
});
}
}
useEffect(() => {
document.addEventListener("keydown", handleDocumentKeyDown);
return () => {
document.removeEventListener("keydown", handleDocumentKeyDown);
};
}, []);
console.log("elements:", state.elements.length);
return (
<div>
<input id="input" type="text" onChange={handleInputChange} value={state.value} />
<div id="count">{state.elements.length}</div>
<button>a</button>
<button>b</button>
<button>c</button>
</div>
);
};
ReactDOM.createRoot(document.body).render(<App />);
</script>
useEffect triggered after react completed its render phase & flush the new changes to the DOM.
In your case you have two useEffects. The first one register your event lister which will then update your component state when input field change. This triggers a state update.( because of the setState )
So React will start render the component again & finish the cycle. And now you have 2nd useEffect which has state in dependency array. Since the state was updated & the new changes are committed to the DOM, react will execute 2nd useEffect logic.
Since your 2nd useEffect just assign some values to a normal variable React will not go re render your component again.
Based on your requirement you don't need a 2nd useEffect. You can use a useMemo,
let elements = useMemo(() => {
const selectors = "button";
const pattern = new RegExp(state.value === "" ? "^$" : state.value);
return Array.from(document.querySelectorAll(selectors)).filter(
(element) => {
if (element.childNodes) {
const nodeWithText = Array.from(element.childNodes).find(
(childNode) => childNode.nodeType === Node.TEXT_NODE
);
if (nodeWithText) {
// The delay won't happenn if you comment out this conditional statement:
if (nodeWithText.textContent?.match(pattern)) {
return element;
}
}
}
})
}, [state])
Note: You don't need to assign your elements into another state. It just create another unwanted re render in cycle. Since you are just doing a calculation to find out the element array you can do it with the useMemo

React component rendered from object does not get unmounted

I have the following code, where I need to run clean-up when unmounting each component step. I've set a useEffect on each Step to check if the component has been unmounted. When the parent gets a new currentStep it swaps the currently active component but the clean-up never runs. I'm wondering if this has to do with the nature of the component being rendered from an object
const Step1 = () => {
useEffect(() => {
console.log("doing things here");
return () => {
console.log("clean-up should happen here but this won't print")
}
}, []}
}
const StepMap = {
step1: <Step1/>
step2: <Step2/>
step3: <Step3/>
}
const Parent = ({ currentStep }) => {
return (
<div>
{ StepMap[currentStep] }
</div>
)
}
Alternatively this piece of code does run the clean-up, but I do find the former cleaner
const Parent = ({ currentStep }) => {
return (
<div>
{ currentStep === "step1" && StepMap[currentStep]}
{ currentStep === "step2" && StepMap[currentStep]}
</div>
)
}
Why does the first approach not work? is there a way to make it work like the second while keeping a cleaner implementation?
if you want to write javascript inside jsx we have write it inside {} curly braces like this:
import React, { useEffect, useState } from "react";
const Step1 = () => {
useEffect(() => {
console.log("Step1 doing things here");
return () => {
console.log("Step1 clean-up should happen here but this won't print");
};
}, []);
return <div>stepOne</div>;
};
const Step2 = () => {
useEffect(() => {
console.log("Step2 doing things here");
return () => {
console.log("Step2 clean-up should happen here but this won't print");
};
}, []);
return <div>steptw0</div>;
};
const Step3 = () => {
useEffect(() => {
console.log("Step3 doing things here");
return () => {
console.log("Step3 clean-up should happen here but this won't print");
};
}, []);
return <div>stepthree</div>;
};
export const StepMap = {
step1: <Step1 />,
step2: <Step2 />,
step3: <Step3 />,
};
export const Parent = ({ currentStep }) => {
return <div>{StepMap[currentStep]}</div>;
};
const App = () => {
const [steps, setSteps] = React.useState("step1");
React.useEffect(() => {
setTimeout(() => setSteps("step2"), 5000);
setTimeout(() => setSteps("step3"), 15000);
}, []);
return <Parent currentStep={steps} />;
};
export default App;

Make a React component rerender when data class property change

In my Typescript app, there's a class that represents some data. This class is being shared end to end (both front-and-back ends use it to structure the data). It has a property named items which is an array of numbers.
class Data {
constructor() {
this.items = [0];
}
addItem() {
this.items = [...this.items, this.items.length];
}
}
I'm trying to render those numbers in my component, but since modifying the class instance won't cause a re-render, I have to "force rerender" to make the new items values render:
const INSTANCE = new Data();
function ItemsDisplay() {
const forceUpdate = useUpdate(); // from react-use
useEffect(() => {
const interval = setInterval(() => {
INSTANCE.addItem();
forceUpdate(); // make it work
}, 2000);
return () => clearInterval(interval);
}, []);
return (
<div>
<h1>with class:</h1>
<div>{INSTANCE.items.map(item => <span>{item}</span>)}</div>
</div>
);
}
While this works, it has one major drawback: addItem() is not the only modification done to INSTANCE; This class has actually around 10 to 15 properties that represent different data parts. So, doing forceUpdate() wherever a modification happens is a nightmare. Not no mention, if this instance will be modified outside the component, I won't be able to forceUpdate() to sync the change with the component.
Using useState([]) to represent items will solve this issue, but as I said Data has a lot of properties, so as some functions. That's another nightmare.
I would like to know what's the best way of rendering data from a class instance, without rerender hacks or unpacking the whole instance into a local component state.
Thanks!
Here's a Codesandbox demo that shows the differences between using a class and a local state.
Here is an example of how you can make Data instance observable and use Effect in your components to observe changes in Data instance items:
const { useState, useEffect } = React;
class Data {
constructor() {
this.data = {
users: [],
products: [],
};
this.listeners = [];
}
addItem(type, newItem) {
this.data[type] = [...this.data[type], newItem];
//notify all listeners that something has been changed
this.notify();
}
addUser(user) {
this.addItem('users', user);
}
addProduct(product) {
this.addItem('products', product);
}
reset = () => {
this.data.users = [];
this.data.products = [];
this.notify();
};
notify() {
this.listeners.forEach((l) => l(this.data));
}
addListener = (fn) => {
this.listeners.push(fn);
//return the remove listener function
return () =>
(this.listeners = this.listeners.filter(
(l) => l !== fn
));
};
}
const instance = new Data();
let counter = 0;
setInterval(() => {
if (counter < 10) {
if (counter % 2) {
instance.addUser({ userName: counter });
} else {
instance.addProduct({ productId: counter });
}
counter++;
}
}, 500);
//custom hook to use instance
const useInstance = (instance, fn = (id) => id) => {
const [items, setItems] = useState(fn(instance.data));
useEffect(
() =>
instance.addListener((items) => setItems(fn(items))),
[instance, fn]
);
return items;
};
const getUsers = (data) => data.users;
const getProducts = (data) => data.products;
const Users = () => {
const users = useInstance(instance, getUsers);
return <pre>{JSON.stringify(users)}</pre>;
};
const Products = () => {
const products = useInstance(instance, getProducts);
return <pre>{JSON.stringify(products)}</pre>;
};
const App = () => {
const reset = () => {
instance.reset();
counter = 0;
};
return (
<div>
<button onClick={reset}>Reset</button>
<div>
users:
<Users />
</div>
<div>
products:
<Products />
</div>
</div>
);
};
ReactDOM.render(<App />, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>
<div id="root"></div>

Categories