Reassigning references to elements based on state - reactjs

I have an array that I am looping through to create div elements, and based on which of these divs is 'active' (derived from state) I want them to have the ref property. This ref is also the parameter for a hook I'm using that adds an onclick listener to that ref. This works ok when the first div is active, however when my state changes to make the 2nd div active, the onclick listener isn't active on the 2nd div. Any idea on how I can resolve this?
Component file:
const [active, setActive] = useState(0);
const ref = useRef<HTMLDivElement>(null);
const { element } = useClick(ref);
return (
<div style={{ position: 'relative' }}>
{cards.map((card, index) => (
<div
{...(index === active ? { ref } : { 'aria-hidden': 'true' })}
>
<Card>
<div className={styles.title}>{card}</div>
</Card>
</div>
))}
</div>
);
Custom hook:
const useClick = (
element: RefObject<HTMLElement>
): {
element: RefObject<HTMLElement>;
} => {
const onClick = (): void => {
console.log('clicked');
};
useEffect(() => {
if (!element.current) {
return undefined;
}
element.current.addEventListener('click', onClick);
return (): void => {
element.current?.removeEventListener('click', onClick);
};
}, [element]);
return { element };
};
export default useClick;
I'd expect the onclick handler to be removed from the first element when it is no longer active and for this to be moved to the 2nd div in the loop. I tried an approach with using an array of refs so that each div had a unique ref but wasn't able to achieve this when using the ref index in the parameter of my custom hook. I can see my state is changing (active) correctly but I've redacted this code as its quite long and complex with IP and doesn't seem to coincide with the problem

If all you care about is the onClick, use it directly without a ref and setting it manually in useEffect:
<div
{...(index === active
? { onClick }
: { 'aria-hidden': 'true' })
}
>
Why doesn't it work?
The ref that you're using is an object reference, which have the current property (ref = { current }). While current changes according to the div it's used on ref stays the same, and the useEffect is not triggered.
Change the dependencies to look at element.current, and also add onClick because it's a dependency of useEffect as well:
useEffect(() => {
if (!element.current) {
return undefined;
}
element.current.addEventListener('click', onClick);
return (): void => {
element.current?.removeEventListener('click', onClick);
};
}, [element.current, onClick]);
Another option is to use a function ref with useState:
const [active, setActive] = useState(0);
const [ref, setRef] = useState(null); // the state is used as ref
const { element } = useClick(ref);
<div
// use setRef as a function ref
{...(index === active ? { ref: setRef } : { 'aria-hidden': 'true' })}
>
// in your custom hook
useEffect(() => {
// in this case element doesn't have current
if (!element) return undefined;
element?.addEventListener('click', onClick);
return (): void => {
element?.removeEventListener('click', onClick);
};
}, [element]);

Related

React Typescript: useRef to listen outside touch event doesn't work

I have a component (navBar) that has two instances: Default and Responsive. Its shape on the screen is being defined by the style (sass), properly regulated by
handleClick function.
I decided to put a listener on the body element, and if there is tapping outside Menu, the function would manually take 'active' className out of Menu classList.
function useOutsideListener(ref: React.MutableRefObject<null | HTMLDivElement>) {
useEffect(() => {
function handleClickOutside(event: MouseEvent | TouchEvent) {
if (ref.current && !ref.current.contains(event.target as Element)) {
activeMenu.forEach((itens) => {
itens.classList.remove(styles['active']);
});
};
};
document.addEventListener("touchstart", handleClickOutside);
return () => {
document.removeEventListener("touchstart", handleClickOutside);
};
}, [ref]);
}
The wrapper is properly attached to the ref attribute, and the logic works as it should, but only after saving the file in vsCode and vite 're-rendering' the component. I know it's related to useRef, but I don't know a way to solve the problem.
function renderNavBar() {
const wrapperRef = useRef<HTMLDivElement>(null);
useOutsideListener(wrapperRef);
return (
<div className={styles.navBar} ref={wrapperRef}>
<button className={styles.menu} onClick={ handleClick } data-menu>MENU</button>
<nav className={styles.navContainer} data-menu>
<ul className={styles.navList}>
<Link to="/exp">Experiência</Link>
<Link to="/formacao">Formação</Link>
<Link to="/projects">Projetos</Link>
<Link to="/contact">Contato</Link>
</ul>
</nav>
</div>
);
}
I was probably using tools I know little about the wrong way. I ended up discovering the useCallback and monitoring the ref property more accurately. The final functional result looks like this:
const useOutsideListener = (): [
RefObject<HTMLDivElement>,
(node: HTMLDivElement) => void
] => {
const ref = useRef<HTMLDivElement | null>(null);
const setRef = useCallback((node: HTMLDivElement) => {
ref.current = node;
}, [ref.current]);
return [ref, setRef]
}
Using useEffect, I monitored the ref object state changes, and for a simple check, removed the style class.
useEffect(() => {
if(ref.current?.attributes[1].value === 'true') {
document.body.addEventListener('touchstart', function handleOutside(event) {
setTimeout(() => {
const menuButton = (event.target as HTMLElement).parentElement;
if(menuButton !== ref.current) {
ref.current?.querySelector('nav')?.classList.remove(styles['active']);
document.body.removeEventListener('touchstart', handleOutside);
}}, 75);
});
};
}, [ref.current?.attributes[1].value]);

Scroll to element on page load with React Hooks

I'm trying to create a functional component that fetches data from an API and renders it to a list. After the data is fetched and rendered I want to check if the URL id and list item is equal, if they are then the list item should be scrolled into view.
Below is my code:
import React, { Fragment, useState, useEffect, useRef } from "react";
export default function ListComponent(props) {
const scrollTarget = useRef();
const [items, setItems] = useState([]);
const [scrollTargetItemId, setScrollTargetItemId] = useState("");
useEffect(() => {
const fetchData = async () => {
let response = await fetch("someurl").then((res) => res.json());
setItems(response);
};
fetchData();
if (props.targetId) {
setScrollTargetItemId(props.targetId)
}
if (scrollTarget.current) {
window.scrollTo(0, scrollTarget.current.offsetTop)
}
}, [props]);
let itemsToRender = [];
itemsToRender = reports.map((report) => {
return (
<li
key={report._id}
ref={item._id === scrollTargetItemId ? scrollTarget : null}
>
{item.payload}
</li>
);
});
return (
<Fragment>
<ul>{itemsToRender}</ul>
</Fragment>
);
}
My problem here is that scrollTarget.current is always undefined. Please advice what I'm doing wrong. Thanks in advance.
Using useCallback, as #yagiro suggested, did the trick!
My code ended up like this:
const scroll = useCallback(node => {
if (node !== null) {
window.scrollTo({
top: node.getBoundingClientRect().top,
behavior: "smooth"
})
}
}, []);
And then I just conditionally set the ref={scroll} on the node that you want to scroll to.
That is because when a reference is changed, it does not cause a re-render.
From React's docs: https://reactjs.org/docs/hooks-reference.html#useref
Keep in mind that useRef doesn’t notify you when its content changes. Mutating the .current property doesn’t cause a re-render. If you want to run some code when React attaches or detaches a ref to a DOM node, you may want to use a callback ref instead.
constructor(props) {
thi.modal = React.createRef();
}
handleSwitch() {
// debugger
this.setState({ errors: [] }, function () {
this.modal.current.openModal('signup') // it will call function of child component of Modal
});
// debugger
}
return(
<>
<button className="login-button" onClick={this.handleSwitch}>Log in with email</button>
<Modal ref={this.modal} />
</>
)
React Hooks will delay the scrolling until the page is ready:
useEffect(() => {
const element = document.getElementById('id')
if (element)
element.scrollIntoView({ behavior: 'smooth' })
}, [])
If the element is dynamic and based on a variable, add them to the Effect hook:
const [variable, setVariable] = useState()
const id = 'id'
useEffect(() => {
const element = document.getElementById(id)
if (element)
element.scrollIntoView({ behavior: 'smooth' })
}, [variable])

How do I assign ref to each element rendered by array mapping and how do I reference them

What I need: I need to assign specific ref to each div, so I can detect the click outside each specific input field, but since the items are rendered by mapping all divs have same reference. For example, when I click the div of item2 it detects that it's the ref so it doesn't trigger the alert.
const ref = useRef(null)
const handleClickOutside = (e) => {
if (ref.current && !ref.current.contains(e.target)) {
alert('clicked')
}
}
useEffect(() => {
document.addEventListener('click', handleClickOutside, true)
return () => {
document.removeEventListener('click', handleClickOutside, true)
}
})
myList = ['item1', 'item2', 'item3']
myList.map(item => {
return (
<div key={item._id} ref={ref}>
<input type="text" value={item.value} />
</div>
)
}
The myList is a representation of my state and are dynamic
Initialize the reference as an array, and for each index assign the related reference.
const listRef = useRef([]);
myList.map((item, i) => {
return (
<div key={item._id} ref={(ref) => (listRef.current[i] = ref)}>
<input type="text" value={item.value} />
</div>
);
});
// Now each array entry contains a reference to `div` element.
// access the reference through index.
listRef.current[i].contains(e.target);
Bonus: here is an example of useOnClickOutside custom hooks implementation:
import { useEffect } from 'react';
function useOnClickOutside(ref, handler) {
useEffect(
() => {
const listener = event => {
// Do nothing if clicking ref's element or descendent elements
if (!ref.current || ref.current.contains(event.target)) {
return;
}
handler(event);
};
document.addEventListener(`mousedown`, listener);
document.addEventListener(`touchstart`, listener);
return () => {
document.removeEventListener(`mousedown`, listener);
document.removeEventListener(`touchstart`, listener);
};
},
// Add ref and handler to effect dependencies
// It's worth noting that because passed in handler is a new ...
// ... function on every render that will cause this effect ...
// ... callback/cleanup to run every render. It's not a big deal ...
// ... but to optimize you can wrap handler in useCallback before ...
// ... passing it into this hook.
[ref, handler],
);
}
export default useOnClickOutside;

Is it safe to use ref.current as useEffect's dependency when ref points to a DOM element?

I'm aware that ref is a mutable container so it should not be listed in useEffect's dependencies, however ref.current could be a changing value.
When a ref is used to store a DOM element like <div ref={ref}>, and when I develop a custom hook that relies on that element, to suppose ref.current can change over time if a component returns conditionally like:
const Foo = ({inline}) => {
const ref = useRef(null);
return inline ? <span ref={ref} /> : <div ref={ref} />;
};
Is it safe that my custom effect receiving a ref object and use ref.current as a dependency?
const useFoo = ref => {
useEffect(
() => {
const element = ref.current;
// Maybe observe the resize of element
},
[ref.current]
);
};
I've read this comment saying ref should be used in useEffect, but I can't figure out any case where ref.current is changed but an effect will not trigger.
As that issue suggested, I should use a callback ref, but a ref as argument is very friendly to integrate multiple hooks:
const ref = useRef(null);
useFoo(ref);
useBar(ref);
While callback refs are harder to use since users are enforced to compose them:
const fooRef = useFoo();
const barRef = useBar();
const ref = element => {
fooRef(element);
barRef(element);
};
<div ref={ref} />
This is why I'm asking whether it is safe to use ref.current in useEffect.
It isn't safe because mutating the reference won't trigger a render, therefore, won't trigger the useEffect.
React Hook useEffect has an unnecessary dependency: 'ref.current'.
Either exclude it or remove the dependency array. Mutable values like
'ref.current' aren't valid dependencies because mutating them doesn't
re-render the component. (react-hooks/exhaustive-deps)
An anti-pattern example:
const Foo = () => {
const [, render] = useReducer(p => !p, false);
const ref = useRef(0);
const onClickRender = () => {
ref.current += 1;
render();
};
const onClickNoRender = () => {
ref.current += 1;
};
useEffect(() => {
console.log('ref changed');
}, [ref.current]);
return (
<>
<button onClick={onClickRender}>Render</button>
<button onClick={onClickNoRender}>No Render</button>
</>
);
};
A real life use case related to this pattern is when we want to have a persistent reference, even when the element unmounts.
Check the next example where we can't persist with element sizing when it unmounts. We will try to use useRef with useEffect combo as above, but it won't work.
// BAD EXAMPLE, SEE SOLUTION BELOW
const Component = () => {
const ref = useRef();
const [isMounted, toggle] = useReducer((p) => !p, true);
const [elementRect, setElementRect] = useState();
useEffect(() => {
console.log(ref.current);
setElementRect(ref.current?.getBoundingClientRect());
}, [ref.current]);
return (
<>
{isMounted && <div ref={ref}>Example</div>}
<button onClick={toggle}>Toggle</button>
<pre>{JSON.stringify(elementRect, null, 2)}</pre>
</>
);
};
Surprisingly, to fix it we need to handle the node directly while memoizing the function with useCallback:
// GOOD EXAMPLE
const Component = () => {
const [isMounted, toggle] = useReducer((p) => !p, true);
const [elementRect, setElementRect] = useState();
const handleRect = useCallback((node) => {
setElementRect(node?.getBoundingClientRect());
}, []);
return (
<>
{isMounted && <div ref={handleRect}>Example</div>}
<button onClick={toggle}>Toggle</button>
<pre>{JSON.stringify(elementRect, null, 2)}</pre>
</>
);
};
See another example in React Docs: How can I measure a DOM node?
Further reading and more examples see uses of useEffect
2021 answer:
This article explains the issue with using refs along with useEffect: Ref objects inside useEffect Hooks:
The useRef hook can be a trap for your custom hook, if you combine it with a useEffect that skips rendering. Your first instinct will be to add ref.current to the second argument of useEffect, so it will update once the ref changes.
But the ref isn’t updated till after your component has rendered — meaning, any useEffect that skips rendering, won’t see any changes to the ref before the next render pass.
Also as mentioned in this article, the official react docs have now been updated with the recommended approach (which is to use a callback instead of a ref + effect). See How can I measure a DOM node?:
function MeasureExample() {
const [height, setHeight] = useState(0);
const measuredRef = useCallback(node => {
if (node !== null) {
setHeight(node.getBoundingClientRect().height);
}
}, []);
return (
<>
<h1 ref={measuredRef}>Hello, world</h1>
<h2>The above header is {Math.round(height)}px tall</h2>
</>
);
}
I faced the same problem and I created a custom hook with Typescript and an official approach with ref callback. Hope that it will be helpful.
export const useRefHeightMeasure = <T extends HTMLElement>() => {
const [height, setHeight] = useState(0)
const refCallback = useCallback((node: T) => {
if (node !== null) {
setHeight(node.getBoundingClientRect().height)
}
}, [])
return { height, refCallback }
}
I faced a similar problem wherein my ESLint complained about ref.current usage inside a useCallback. I added a custom hook to my project to circumvent this eslint warning. It toggles a variable to force re-computation of the useCallback whenever ref object changes.
import { RefObject, useCallback, useRef, useState } from "react";
/**
* This hook can be used when using ref inside useCallbacks
*
* Usage
* ```ts
* const [toggle, refCallback, myRef] = useRefWithCallback<HTMLSpanElement>();
* const onClick = useCallback(() => {
if (myRef.current) {
myRef.current.scrollIntoView({ behavior: "smooth" });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [toggle]);
return (<span ref={refCallback} />);
```
* #returns
*/
function useRefWithCallback<T extends HTMLSpanElement | HTMLDivElement | HTMLParagraphElement>(): [
boolean,
(node: any) => void,
RefObject<T>
] {
const ref = useRef<T | null>(null);
const [toggle, setToggle] = useState(false);
const refCallback = useCallback(node => {
ref.current = node;
setToggle(val => !val);
}, []);
return [toggle, refCallback, ref];
}
export default useRefWithCallback;
I've stopped using useRef and now just use useState once or twice:
const [myChart, setMyChart] = useState(null)
const [el, setEl] = useState(null)
useEffect(() => {
if (!el) {
return
}
// attach to element
const myChart = echarts.init(el)
setMyChart(myChart)
return () => {
myChart.dispose()
setMyChart(null)
}
}, [el])
useEffect(() => {
if (!myChart) {
return
}
// do things with attached object
myChart.setOption(... data ...)
}, [myChart, data])
return <div key='chart' ref={setEl} style={{ width: '100%', height: 1024 }} />
Useful for charting, auth and other non-react libraries, because it keeps an element ref and the initialized object around and can dispose of it directly as needed.
I'm now not sure why useRef exists in the first place...?

How to optimize React components with React.memo and useCallback when callbacks are changing state in the parent

I've come accross a performance optimization issue that I feel could be fixed somehow but I'm not sure how.
Suppose I have a collection of objects that I want to be editable. The parent component contains all objects and renders a list with an editor component that shows the value and also allows to modify the objects.
A simplified example would be this :
import React, { useState } from 'react'
const Input = props => {
const { value, onChange } = props
handleChange = e => {
onChange && onChange(e.target.value)
}
return (
<input value={value} onChange={handleChange} />
)
}
const ObjectEditor = props => {
const { object, onChange } = props
return (
<li>
<Input value={object.name} onChange={onChange('name')} />
</li>
)
}
const Objects = props => {
const { initialObjects } = props
const [objects, setObjects] = useState(initialObjects)
const handleObjectChange = id => key => value => {
const newObjects = objects.map(obj => {
if (obj.id === id) {
return {
...obj,
[key]: value
}
}
return obj
})
setObjects(newObjects)
}
return (
<ul>
{
objects.map(obj => (
<ObjectEditor key={obj.id} object={obj} onChange={handleObjectChange(obj.id)} />
))
}
</ul>
)
}
export default Objects
So I could use React.memo so that when I edit the name of one object the others don't rerender. However, because of the onChange handler being recreated everytime in the parent component of ObjectEditor, all objects always render anyways.
I can't solve it by using useCallback on my handler since I would have to pass it my objects as a dependency, which is itself recreated everytime an object's name changes.
It seems to me like it is not necessary for all the objects that haven't changed to rerender anyway because the handler changed. And there should be a way to improve this.
Any ideas ?
I've seen in the React Sortly repo that they use debounce in combination with each object editor changing it's own state.
This allows only the edited component to change and rerender while someone is typing and updates the parent only once if no other change event comes up in a given delay.
handleChangeName = (e) => {
this.setState({ name: e.target.value }, () => this.change());
}
change = debounce(() => {
const { index, onChange } = this.props;
const { name } = this.state;
onChange(index, { name });
}, 300);
This is the best solution I can see right now but since they use the setState callback function I haven't been able to figure out a way to make this work with hooks.
You have to use the functional form of setState:
setState((prevState) => {
// ACCESS prevState
return someNewState;
});
You'll be able to access the current state value (prevState) while updating it.
Then way you can use the useCallback hook without the need of adding your state object to the dependency array. The setState function doesn't need to be in the dependency array, because it won't change accross renders.
Thus, you'll be able to use React.memo on the children, and only the ones that receive different props (shallow compare) will re-render.
EXAMPLE IN SNIPPET BELOW
const InputField = React.memo((props) => {
console.log('Rendering InputField '+ props.index + '...');
return(
<div>
<input
type='text'
value={props.value}
onChange={()=>
props.handleChange(event.target.value,props.index)
}
/>
</div>
);
});
function App() {
console.log('Rendering App...');
const [inputValues,setInputValues] = React.useState(
['0','1','2']
);
const handleChange = React.useCallback((newValue,index)=>{
setInputValues((prevState)=>{
const aux = Array.from(prevState);
aux[index] = newValue;
return aux;
});
},[]);
const inputItems = inputValues.map((item,index) =>
<InputField
value={item}
index={index}
handleChange={handleChange}
/>
);
return(
<div>
{inputItems}
</div>
);
}
ReactDOM.render(<App/>, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.3/umd/react-dom.production.min.js"></script>
<div id="root"/>
Okay, so it seems that debounce works if it's wrapped in useCallback
Not sure why it doesn't seem to be necessary to pass newObject as a dependency in the updateParent function though.
So to make this work I had to make the following changes :
First, useCallback in the parent and change it to take the whole object instead of being responsible for updating the keys.
Then update the ObjectEditor to have its own state and handle the change to the keys.
And wrap the onChange handler that will update the parent in the debounce
import React, { useState, useEffect } from 'react'
import debounce from 'lodash.debounce'
const Input = props => {
const { value, onChange } = props
handleChange = e => {
onChange && onChange(e.target.value)
}
return (
<input value={value} onChange={handleChange} />
)
}
const ObjectEditor = React.memo(props => {
const { initialObject, onChange } = props
const [object, setObject] = useState(initialObject)
const updateParent = useCallback(debounce((newObject) => {
onChange(newObject)
}, 500), [onChange])
// synchronize the object if it's changed in the parent
useEffect(() => {
setObject(initialObject)
}, [initialObject])
const handleChange = key => value => {
const newObject = {
...object,
[key]: value
}
setObject(newObject)
updateParent(newObject)
}
return (
<li>
<Input value={object.name} onChange={handleChange('name')} />
</li>
)
})
const Objects = props => {
const { initialObjects } = props
const [objects, setObjects] = useState(initialObjects)
const handleObjectChange = useCallback(newObj => {
const newObjects = objects.map(obj => {
if (newObj.id === id) {
return newObj
}
return obj
})
setObjects(newObjects)
}, [objects])
return (
<ul>
{
objects.map(obj => (
<ObjectEditor key={obj.id} initialObject={obj} onChange={handleObjectChange} />
))
}
</ul>
)
}
export default Objects

Resources