Clicking a child component affects parent in an unexpected way - javascript

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>

Related

Input element losing it's focus after key press when it's controlled from outside the Modal (which uses Portal)

[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

multiple dropdown state and handling outside click in React

I have a parent component that holds multiple Dropdown child components. The state is managed within the parent component to show only one dropdown at a time. I currently have part of it working but am having some trouble wrapping my head around the logic to make it so that when you click outside of a dropdown if its open then it will close the dropdown. I have tried using useRef hook to detect click outside, but still having trouble wrapping my head around the logic to make things display correctly.
const MultipleDropdownPage = () => {
const [dropdown, setDropdown] = useState(null);
const handleDropdown = id => {
if (dropdown === id) {
setDropdown(null);
}
if (dropdown !== id) {
setDropdown(id);
}
};
return (
<div>
{dropdown ? dropdown : 'Nothing'}
<Dropdown handleDropdown={handleDropdown} dropdown={dropdown} id='1' />
<Dropdown handleDropdown={handleDropdown} dropdown={dropdown} id='2' />
</div>
);
};
import React from 'react';
const Dropdown = ({ handleDropdown, id, dropdown }) => {
return (
<div>
<button onClick={() => handleDropdown(id)}>Click me</button>
{id === dropdown && (
<div className='dropdown'>
<ul>
<li>Lorem, ipsum.</li>
<li>Dolore, eligendi.</li>
<li>Quam, itaque!</li>
</ul>
</div>
)}
</div>
);
};
export default Dropdown;
Needed to set a class on the button itself and then check if when the document is clicked it doesn't match that button class
import React, { useRef, useEffect } from 'react';
const Dropdown = ({ handleDropdown, id, dropdown }) => {
const ref = useRef();
useEffect(() => {
const handleClick = e => {
if (!e.target.classList.contains('dropdown-toggle')) {
handleDropdown(null);
}
};
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, [handleDropdown]);
return (
<>
<button
onClick={() => handleDropdown(id)}
ref={ref}
className='dropdown-toggle'
>
Click me
</button>
{id === dropdown && (
<div className='dropdown'>
<ul>
<li>Lorem, ipsum.</li>
<li>Dolore, eligendi.</li>
<li>Quam, itaque!</li>
</ul>
</div>
)}
</>
);
};
export default Dropdown;
This solution takes advantage of the custom HTML data-* attribute.
const MultipleDropdownPage = (props) => {
const [dropdown, setDropdown] = React.useState(null);
const handleDropdown = id => {
if (dropdown === id || dropdown && id == undefined) {
setDropdown(null);
}
if (dropdown !== id) {
setDropdown(id);
}
};
React.useEffect(() => {
const handleClick = ({target}) => {
handleDropdown(target.dataset.id);
};
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick)
}, [handleDropdown]);
return (
<div className="dropdown-container">
{dropdown ? dropdown : 'Nothing'}
<Dropdown dropdown={dropdown} id='1' />
<Dropdown dropdown={dropdown} id='2' />
</div>
);
};
const Dropdown = ({ id, dropdown }) => {
return (
<div>
<button data-id={id}>Click me</button>
{id === dropdown && (
<div className='dropdown'>
<ul>
<li>Lorem, ipsum.</li>
<li>Dolore, eligendi.</li>
<li>Quam, itaque!</li>
</ul>
</div>
)}
</div>
);
};
ReactDOM.render(<MultipleDropdownPage />, document.getElementById("root"));
.dropdown-container {
padding: 5px;
border: 2px solid red;
width: fit-content;
margin: 0 auto;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.1/umd/react-dom.production.min.js"></script>
<div id="root"></div>

Adding hovering effects with a timeout in React

See codesandbox here
I am trying to add a modal that shows up with a delay when hovering over a div. However, it's getting a bit tricky because, for example, if the timeout interval is 1000ms, and you hover over said div and then hover away from that div within the 1000ms, the modal will still show up. What I want to happen is for the modal to show up after the delay (e.g. 1000ms) only if you have maintained mouseover over the div for that delay period. How can I create this effect instead of the side effects I'm seeing now? Thanks!
index.tsx:
import * as React from "react";
import ReactDOM from "react-dom";
import "./styles.css";
const Modal: React.FC = () => {
const divRef = React.useRef<HTMLDivElement>(null);
const [showModal, setShowModal] = React.useState<boolean>(false);
React.useEffect(() => {
const divNode = divRef.current;
const handleEvent = (event: Event): void => {
if (divNode) {
if (divNode.contains(event.target as Node)) {
setTimeout(() => setShowModal(true), 1000);
} else {
setShowModal(false);
}
}
};
document.addEventListener("mouseover", handleEvent);
return () => {
document.removeEventListener("mouseover", handleEvent);
};
}, [divRef]);
return (
<div className="container">
<div className="div" ref={divRef}>
Hover Me
</div>
{showModal && <div className="modal">modal</div>}
</div>
);
};
const App: React.FC = () => (
<>
<Modal />
<Modal />
<Modal />
<Modal />
</>
);
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
You should add a mouse out event that will hide the modal.
Call a funtion on 'mouseout' event listener and set the showModal to false. In that way, it will hide the modal if you move your mouse any time.
setShowModal(false)
Updated: Can you also set timeout to a variable and then on mouseout fire clearTimeout(variable_that_set_to_timeout)
React.useEffect(() => {
const divNode = divRef.current;
let timeout = null;
const handleEvent = (event: Event): void => {
if (divNode) {
if (divNode.contains(event.target as Node)) {
timeout = setTimeout(() => setShowModal(true), 1000);
} else {
setShowModal(false);
}
}
};
const hideModal = (event: Event): void => {
clearTimeout(timeout);
setShowModal(false);
};
divNode.addEventListener("mouseover", handleEvent);
divNode.addEventListener("mouseout", hideModal);
return () => {
document.removeEventListener("mouseover", handleEvent);
};
}, [divRef]);
Link of sandbox
You should really avoid changing the DOM when working with react. React isn't jQuery.
you could try making this your modal code:
const Modal: React.FC = () => {
const [timeout, setModalTimeout] = React.useState(null);
const [showModal, setShowModal] = React.useState<boolean>(false);
return (
<div className="container">
<div className="div" onMouseEnter={() => {
timeout && !showModal && clearTimeout(timeout);
setModalTimeout(setTimeout(() => setShowModal(true), 1000))
}} onMouseLeave={() => {
timeout && clearTimeout(timeout)
setShowModal(false);
}}>
Hover Me
</div>
{showModal && <div className="modal">modal</div>}
</div>
);
};
Sources:
https://reactjs.org/docs/refs-and-the-dom.html
https://reactjs.org/docs/react-dom.html
The proper way to do this would be to create a useTimeout hook and manage maintain the state of the hover.
import { useState } from "react";
import useTimeout from "./useTimeout";
export default function App() {
const [visible, setVisible] = useState(false);
const [hovered, setHovered] = useState(false);
//close after 3s
useTimeout(() => setVisible(true), !visible && hovered ? 3000 : null);
return (
<div className="App">
<h1>Hover Timeout Example</h1>
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
Hover me for 3s to show modal
<div>Hover status: {hovered ? "true" : "false"}</div>
</div>
{visible && (
<div>
<h1>Modal</h1>
<div>
<button onClick={() => setVisible(false)}>close</button>
</div>
</div>
)}
</div>
);
}
Code Sandbox

Event handler get's called before handler is set

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

Can i set state in parent from child using useEffect hook in 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} />;
};

Categories