I have this reusable component in my react app.
export const OutsideWrapper = ({ children, onOutside, className }) => {
const wrapperRef = useRef(null);
const [style, setStyles] = useState({
opacity: 1
});
useEffect(() => {
console.log("1. component was mounted");
const i = e => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
console.log("3. outside click was trigerred");
e.preventDefault();
setStyles({ opacity: 0 });
setTimeout(() => {
onOutside();
}, 100);
}
};
window.addEventListener("click", i, true);
console.log('2. listener was added');
return () => {
console.log("4. listerner was removed");
window.removeEventListener("click", i, true);
};
}, [onOutside]);
return (
<div
ref={wrapperRef}
style={style}
className={`outside-wrapper ${className}`}
>
{children}
</div>
);
};
When this OutsideWrapper component get's rendered, it should add event listener to the document, then listen to an event, call onOutside function and then unmount. (onOutside unmounts the component). After that listener gets removed.
But when component get's rendered, it immediately calls onOutside and unmounts.
Here's the part of parent component:
const [down, setDown] = useState(false);
return (
<input onFocus={()=>setDown(true)}/>
{down && (
<OutsideWrapper
onOutside={() => setDown(false)}
className="input-wrapper"
>
<DropDownList
items={dropDownItems}
term={data.location}
onChoose={onChoose}
/>
</OutsideWrapper>
)}
)
The window.addEventListener call inside useEffect is being called when React's rendering the component, which happens on focus. The event that causes focus isn't the click though, it's a mousedown. When the next mouseup happens, a click event is also generated which is caught. Notice that if you tab into the input to focus it doesn't cause the bug.
There are a couple approaches to fix this, but my recommendation is to ignore click events that happen on the input itself.
Here's an example: I've added a ref to the <input>, passed it into OutsideWrapper, and added a check like you have for wrapperRef for the new ref.
function Test() {
const [down, setDown] = React.useState(false);
const focusRef = React.useRef();
return (
<div className="App">
<h1>Hello CodeSandbox</h1>
<h2>
<input ref={focusRef} onFocus={() => setDown(true)} />
{down && (
<OutsideWrapper
onOutside={() => setDown(false)}
focusedRef={focusRef}
className="input-wrapper"
>
children
</OutsideWrapper>
)}
</h2>
</div>
);
}
const OutsideWrapper = ({ children, onOutside, className, focusedRef }) => {
const wrapperRef = React.useRef(null);
const [style, setStyles] = React.useState({
opacity: 1
});
React.useEffect(() => {
console.log("1. component was mounted");
const i = e => {
console.log(e.target, wrapperRef.current);
if (
wrapperRef.current &&
!wrapperRef.current.contains(e.target) &&
focusedRef.current !== e.target
) {
console.log("3. outside click was trigerred");
e.preventDefault();
setStyles({ opacity: 0 });
setTimeout(() => {
onOutside();
}, 100);
}
};
window.addEventListener("click", i, true);
console.log("2. listener was added");
return () => {
console.log("4. listerner was removed");
window.removeEventListener("click", i, true);
};
}, [onOutside, focusedRef]);
return (
<div
ref={wrapperRef}
style={style}
className={`outside-wrapper ${className}`}
>
{children}
</div>
);
};
Related
I have two components, Container and Item.
Container contains Item.
Item contains a button and a div.
These components have the following behaviors:
Container: When I click outside of Container it should disappear, I'm achieving this by using a custom hook that detects clicks outside of components. This works just fine.
Item: When I click in the div which is inside Item it should disappear, I'm achieving this by setting a boolean state. This also works but the problem here is that Container also disappears.
Container
const Container = ({ setDisplay }) => {
const containerRef = useRef(null);
useClickOutside(containerRef, () => {
//code to make Container disappear that is not relevant for the issue
setDisplay(false)
});
return (
<div ref={containerRef} className='container'>
<Item />
</div>
);
};
Item
const Item = () => {
const [displayItem, setDisplayItem] = useState(false);
return (
<div>
<button onClick={() => setDisplayItem(true)}>Show Item's content</button>
{displayItem && (
<div
className='item-content'
onClick={() => setDisplayItem(false)}
/>
)}
</div>
);
};
useClickOutside
const useClickOutside = (ref, handler) => {
useEffect(() => {
const trigger = e => {
if (!(ref?.current?.contains(e.target))) handler();
}
document.addEventListener('click', trigger);
return () => document.removeEventListener('click', trigger);
}, [handler, ref])
}
Why is this happening and how can I prevent it?
Note: I have to use that hook.
Both the listeners are being attached to the bubbling phase, so the inner ones trigger first.
When the item is shown, and when it's clicked, this runs:
<div
className='item-content'
onClick={() => setDisplayItem(false)}
>item content</div>
As a result, before the event propagates outward, setDisplayItem(false) causes this .item-content element to be removed from the DOM. See here, how the parent no longer exists afterwards:
const Container = ({ setDisplay }) => {
const containerRef = React.useRef(null);
useClickOutside(containerRef, () => {
//code to make Container disappear that is not relevant for the issue
console.log('making container disappear');
});
return (
<div ref={containerRef} className='container'>
container
<Item />
</div>
);
};
const Item = () => {
const [displayItem, setDisplayItem] = React.useState(false);
return (
<div>
<button onClick={() => setDisplayItem(true)}>Show Item's content</button>
{displayItem && (
<div
className='item-content'
onClick={() => setDisplayItem(false)}
>item content</div>
)}
</div>
);
};
const useClickOutside = (ref, handler) => {
React.useEffect(() => {
const trigger = e => {
console.log(e.target.parentElement);
if (!(ref.current.contains(e.target))) handler();
}
document.addEventListener('click', trigger);
return () => document.removeEventListener('click', trigger);
}, [handler, ref])
}
ReactDOM.render(<Container />, document.querySelector('.react'));
<script crossorigin src="https://unpkg.com/react#16/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom#16/umd/react-dom.development.js"></script>
<div class='react'></div>
You can fix this by changing useClickOutside to also check whether the node is connected or not. If it's not connected, the element is no longer in the DOM due to a state change and rerender - so the click that was made wasn't definitely outside the ref.current, so the handler shouldn't run.
const trigger = e => {
const { current } = ref;
if (e.target.isConnected && !current.contains(e.target)) {
const Container = ({ setDisplay }) => {
const containerRef = React.useRef(null);
useClickOutside(containerRef, () => {
//code to make Container disappear that is not relevant for the issue
console.log('making container disappear');
});
return (
<div ref={containerRef} className='container'>
container
<Item />
</div>
);
};
const Item = () => {
const [displayItem, setDisplayItem] = React.useState(false);
return (
<div>
<button onClick={() => setDisplayItem(true)}>Show Item's content</button>
{displayItem && (
<div
className='item-content'
onClick={() => setDisplayItem(false)}
>item content</div>
)}
</div>
);
};
const useClickOutside = (ref, handler) => {
React.useEffect(() => {
const trigger = e => {
const { current } = ref;
if (e.target.isConnected && !current.contains(e.target)) {
console.log(current.parentElement);
handler();
}
}
document.addEventListener('click', trigger);
return () => document.removeEventListener('click', trigger);
}, [handler, ref])
}
ReactDOM.render(<Container />, document.querySelector('.react'));
<script crossorigin src="https://unpkg.com/react#16/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom#16/umd/react-dom.development.js"></script>
<div class='react'></div>
[Solved] My input component is losing focus as soon as I press any key only when its value is controlled from outside the portal
NOTE: I am sorry. While writing this, I found the problem in my code, but I decided to post this anyway
[Reason] I was inlining the close function, so the useEffect hook got triggered every time close changed when the component was rendered again due to state changes and thus calling the activeElement.blur() on each keystroke.
Portal
const root = document.getElementById('root')
const modalRoot = document.getElementById('modal-root')
const Portal = ({ children, className, drawer = false }) => {
const element = React.useMemo(() => document.createElement('div'), [])
React.useEffect(() => {
element.className = clsx('modal', className)
modalRoot.appendChild(element)
return () => {
modalRoot.removeChild(element)
}
}, [element, className])
return ReactDOM.createPortal(children, element)
}
Modal
const Modal = (props) => {
const { children, show = false, close, className } = props
const backdrop = React.useRef(null)
const handleTransitionEnd = React.useCallback(() => setActive(show), [show])
const handleBackdropClick = React.useCallback(
({ target }) => target === backdrop.current && close(),
[]
)
const handleKeyUp = React.useCallback(
({ key }) => ['Escape'].includes(key) && close(),
[]
)
React.useEffect(() => {
if (backdrop.current) {
window.addEventListener('keyup', handleKeyUp)
}
if (show) {
root.setAttribute('inert', 'true')
document.body.style.overflow = 'hidden'
document.activeElement.blur?.() // ! CULPRIT
}
return () => {
root.removeAttribute('inert')
document.body.style.overflow = 'auto'
window.removeEventListener('keyup', handleKeyUp)
}
}, [show, close])
return (
<>
{show && (
<Portal className={className}>
<div
ref={backdrop}
onClick={handleBackdropClick}
onTransitionEnd={handleTransitionEnd}
className={clsx('backdrop', show && 'active')}>
<div className="content">{children}</div>
</div>
</Portal>
)}
</>
)
}
Custom Textfield
const TextField = React.forwardRef(
({ label, className, ...props }, ref) => {
return (
<div className={clsx('textfield', className)}>
{label && <label>{label}</label>}
<input ref={ref} {...props} />
</div>
)
}
)
I was inlining the close function, so the useEffect hook got triggered every time close changed when the component was rendered again due to state changes and thus calling the activeElement.blur() on each keystroke.
In Modal.jsx
...
React.useEffect(() => {
...
if (show) {
root.setAttribute('inert', 'true')
document.body.style.overflow = 'hidden'
document.activeElement.blur?.() // ! CULPRIT
}
...
}, [show, close]) // as dependency
...
<Modal
show={show}
close={() => setShow(false)} // this was inlined
className="some-modal"
>
...
</Modal>
TAKEAWAY
Do not inline functions
Usually there is no reason to pass a function (pointer) as dependency
I've tried to use react-dialog-polyfill, but when I test my code, I knew that the onClose event listener was not fired on Safari, even though it worked on Chrome. Below is the code in react-dialog-polyfill.
import dialogPolyfill from 'dialog-polyfill';
const ModalBase = forwardRef((p, modal) => {
const {
children,
open,
ready,
onCancel,
onClose,
useAsModal,
...rest
} = p
useEffect(() => {
const self = modal.current
dialogPolyfill.registerDialog(self)
if (!self || !ready || self.open === open) return
const show = useAsModal ? () => self.showModal() : () => self.show()
const close = () => self.close()
const action = open ? show : close
action()
}, [ready, open, modal, useAsModal])
const onCancelWrap = e => {
e.preventDefault()
onCancel(e, modal.current)
}
const onCloseWrap = e => {
onClose(e, modal.current)
}
return (
<dialog {...rest}
ref={modal}
onCancel={onCancelWrap}
onClose={onCloseWrap}
>
{children}
</dialog>
)
})
Then I discovered that when I comment out the onCancel and onClose part below:
return (
<dialog {...rest}
ref={modal}
//onCancel={onCancelWrap}
//onClose={onCloseWrap}
>
{children}
</dialog>
)
and add native event listener,
useEffect(() => {
const self = modal.current
dialogPolyfill.registerDialog(self);
self.onclose=e => {
onClose(e, self)
}
self.oncancel=e => {
e.preventDefault()
onCancel(e, self)
}
it worked.
I think it is the problem that react doesn't call onClose <dialog onClose={onClose} />
Is this a problem of React?
I have a set of buttons in a child component where when clicked set a corresponding state value true or false. I have a useEffect hook in this child component also with dependencies on all these state values so if a button is clicked, this hook then calls setFilter which is passed down as a prop from the parent...
const Filter = ({ setFilter }) => {
const [cycling, setCycling] = useState(true);
const [diy, setDiy] = useState(true);
useEffect(() => {
setFilter({
cycling: cycling,
diy: diy
});
}, [cycling, diy]);
return (
<Fragment>
<Row>
<Col>
<Button block onClick={() => setCycling(!cycling)}>cycling</Button>
</Col>
<Col>
<Button block onClick={() => setdIY(!DIY)}>DIY</Button>
</Col>
</Row>
</Fragment>
);
};
In the parent component I display a list of items. I have two effects in the parent, one which does an initial load of items and then one which fires whenever the filter is changed. I have removed most of the code for brevity but I think the ussue I am having boils down to the fact that on render of my ItemDashboard the filter is being called twice. How can I stop this happening or is there another way I should be looking at this.
const ItemDashboard = () => {
const [filter, setFilter] = useState(null);
useEffect(() => {
console.log('on mount');
}, []);
useEffect(() => {
console.log('filter');
}, [filter]);
return (
<Container>..
<Filter setFilter={setFilter} />
</Container>
);
}
I'm guessing, you're looking for the way to lift state up to common parent.
In order to do that, you may bind event handlers of child components (passed as props) to desired callbacks within their common parent.
The following live-demo demonstrates the concept:
const { render } = ReactDOM,
{ useState } = React
const hobbies = ['cycling', 'DIY', 'hiking']
const ChildList = ({list}) => (
<ul>
{list.map((li,key) => <li {...{key}}>{li}</li>)}
</ul>
)
const ChildFilter = ({onFilter, visibleLabels}) => (
<div>
{
hobbies.map((hobby,key) => (
<label {...{key}}>{hobby}
<input
type="checkbox"
value={hobby}
checked={visibleLabels.includes(hobby)}
onChange={({target:{value,checked}}) => onFilter(value, checked)}
/>
</label>))
}
</div>
)
const Parent = () => {
const [visibleHobbies, setVisibleHobbies] = useState(hobbies),
onChangeVisibility = (hobby,visible) => {
!visible ?
setVisibleHobbies(visibleHobbies.filter(h => h != hobby)) :
setVisibleHobbies([...visibleHobbies, hobby])
}
return (
<div>
<ChildList list={visibleHobbies} />
<ChildFilter onFilter={onChangeVisibility} visibleLabels={visibleHobbies} />
</div>
)
}
render (
<Parent />,
document.getElementById('root')
)
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.12.0/umd/react.production.min.js"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.11.0/umd/react-dom.production.min.js"></script><div id="root"></div>
Yes, you can, useEffect in child component which depends on the state is also how you typically implement a component which is controlled & uncontrolled:
const NOOP = () => {};
// Filter
const Child = ({ onChange = NOOP }) => {
const [counter, setCounter] = useState(0);
useEffect(() => {
onChange(counter);
}, [counter, onChange]);
const onClick = () => setCounter(c => c + 1);
return (
<div>
<div>{counter}</div>
<button onClick={onClick}>Increase</button>
</div>
);
};
// ItemDashboard
const Parent = () => {
const [value, setState] = useState(null);
useEffect(() => {
console.log(value);
}, [value]);
return <Child onChange={setState} />;
};
Here's my situation:
I've got a custom hook, called useClick, which gets an HTML element and a callback as input, attaches a click event listener to that element, and sets the callback as the event handler.
App.js
function App() {
const buttonRef = useRef(null);
const [myState, setMyState] = useState(0);
function handleClick() {
if (myState === 3) {
console.log("I will only count until 3...");
return;
}
setMyState(prevState => prevState + 1);
}
useClick(buttonRef, handleClick);
return (
<div>
<button ref={buttonRef}>Update counter</button>
{"Counter value is: " + myState}
</div>
);
}
useClick.js
import { useEffect } from "react";
function useClick(element, callback) {
console.log("Inside useClick...");
useEffect(() => {
console.log("Inside useClick useEffect...");
const button = element.current;
if (button !== null) {
console.log("Attaching event handler...");
button.addEventListener("click", callback);
}
return () => {
if (button !== null) {
console.log("Removing event handler...");
button.removeEventListener("click", callback);
}
};
}, [element, callback]);
}
export default useClick;
Note that with the code above, I'll be adding and removing the event listener on every call of this hook (because the callback, which is handleClick changes on every render). And it must change, because it depends on the myState variable, that changes on every render.
And I would very much like to only add the event listener on mount and remove on dismount. Instead of adding and removing on every call.
Here on SO, someone have suggested that I coulde use the following:
useClick.js
function useClick(element, callback) {
console.log('Inside useClick...');
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
const callbackWrapper = useCallback(props => callbackRef.current(props), []);
useEffect(() => {
console.log('Inside useClick useEffect...');
const button = element.current;
if (button !== null) {
console.log('Attaching event handler...');
button.addEventListener('click', callbackWrapper);
}
return () => {
if (button !== null) {
console.log('Removing event handler...');
button.removeEventListener('click', callbackWrapper);
}
};
}, [element, callbackWrapper]);
}
QUESTION
It works as intended. It only adds the event listener on mount, and removes it on dismount.
The code above uses a callback wrapper that uses a ref that will remain the same across renders (so I can use it as the event handler and mount it only once), and its .current property it's updated with the new callback on every render by a useEffect hook.
The question is: performance-wise, which approach is the best? Is running a useEffect() hook less expensive than adding and removing event listeners on every render?
Is there anyway I could test this?
App.js
function App() {
const buttonRef = useRef(null);
const [myState, setMyState] = useState(0);
// handleClick remains unchanged
const handleClick = useCallback(
() => setMyState(prevState => prevState >= 3 ? 3 : prevState + 1),
[]
);
useClick(buttonRef, handleClick);
return (
<div>
<button ref={buttonRef}>Update counter</button>
{"Counter value is: " + myState}
</div>
);
}
A more professional answer:
App.js
function App() {
const buttonRef = useRef(null);
const [myState, handleClick] = useReducer(
prevState => prevState >= 3 ? 3 : prevState + 1,
0
);
useClick(buttonRef, handleClick);
return (
<div>
<button ref={buttonRef}>Update counter</button>
{"Counter value is: " + myState}
</div>
);
}