I have a react js application where I render a list of items. When a user click on an item and after that clicks outside in console.log(id, "checked id"); I should see only that item where user clicked before.
import "./styles.css";
import React, { useEffect, useState } from "react";
const Element = ({ id, setId }) => {
const handleClick = (e) => {
const condition = e.target.className.includes("test");
if (!condition) {
setId(id);
}
};
console.log(id, "checked id");
useEffect(() => {
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
return (
<div
className={"test"}
onClick={() => {
setId(id);
}}
>
hello {id}
</div>
);
};
export default function App() {
const [idSelect, setIdSelect] = useState(0);
return (
<div className="App">
{[0, 1, 2].map((el) => {
return <Element setId={setIdSelect} key={el} id={el} />;
})}
</div>
);
}
Basically I should trigger only that element where user previously clicked. EX: user clicks on hello 0 item and after that clicks outside, in console they should see only console.log(0, "checked id");, meaning that only that component is triggered. Issue: at the moment when user click outside, all the components renders, but I need only where user clicked.
demo: https://codesandbox.io/s/compassionate-einstein-9v1ws?file=/src/App.js:0-821 Question: How to solve my issue?
First you need to move the console.log to the useEffect to control when it will be executed, since you wanted to execute when the document is clicked them do it the clickHandle.
useEffect(() => {
const handleClick = (e) => {
const condition = e.target.className.includes("test");
if (!condition && isSelected) {
console.log(id, "checked id");
setId(id); // You don't need this, it's already set to current id.
}
};
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, [setId, id, isSelected]);
To make sure only the selected Element logs to console i added the attribute isSelected to handle that.
isSelected={idSelect === el}
This should solve the problem, but just to be safe i added stopPropagation to the div click to make sure it doesn't trigger the document click.
onClick={(e) => {
e.stopPropagation();
setId(id);
}}
You can check this sandbox i modified here.
It seems like we have many attributes right, i believe a good solution for that is move the display event to the parent tag, like this.
export default function App() {
const [idSelect, setIdSelect] = useState(0);
useEffect(() => {
const handleClick = (e) => {
console.log(idSelect, "checked id");
}
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
});
return (
<div className="App">
{[0, 1, 2].map((el) => {
return (
<Element
setId={setIdSelect}
key={el}
id={el}
/>
);
})}
</div>
);
}
You can also replace setId and id attributes with a single click event handler like () => setId(id).
If you want the function to be executed as well when the selected id changes, you can use useEffect like this.
useEffect(() => {
console.log(idSelect, "checked id");
}, [idSelect]);
I suggest moving the console.log(idSelect, "checked id"); to a function and call it at both events.
Related
I have a specific problem that is keeping me awake this whole week.
I have a parent component which has a pop-up children component. When I open the page the pop-up shows off and after 5 seconds it disappears with a setTimeout.
This pop-up has an input element in it.
I want the pop-up to disappear after 5 seconds or if I click to digit something in the input. I tried to create a timerRef to the setTimeout and closes it in the children but it didn't work.
Can you help me, please? Thanks in advance.
ParentComponent.tsx
const ParentComponent = () => {
const [isVisible, setIsVisible] = useState(true)
timerRef = useRef<ReturnType<typeof setTimeout>>()
timerRef.current = setTimeout(() => {
setIsVisible(false)
}, 5000)
useEffect(() => {
return () => clearTimeout()
})
return (
<div>
<ChildrenComponent isVisible={isVisible} inputRef={timerRef} />
</div>
)
}
ChildrenComponent.tsx
const ChildrenComponent = ({ isVisible, inputRef}) => {
return (
<div className=`${isVisible ? 'display-block' : 'display-none'}`>
<form>
<input onClick={() => clearTimeout(inputRef.current as NodeJS.Timeout)} />
</form>
</div>
)
}
You're setting a new timer every time the the component re-renders, aka when the state changes which happens in the timeout itself.
timerRef.current = setTimeout(() => {
setIsVisible(false);
}, 5000);
Instead you can put the initialization in a useEffect.
useEffect(() => {
if (timerRef.current) return;
timerRef.current = setTimeout(() => {
setIsVisible(false);
}, 5000);
return () => clearTimeout(timerRef.current);
}, []);
You should also remove the "loose" useEffect that runs on every render, this one
useEffect(() => {
return () => clearTimeout();
});
React 18 changed useEffect timing at it broke my code, that looks like this:
const ContextualMenu = ({ isDisabled }) => {
const [isExpanded, setIsExpanded] = useState(false);
const toggleMenu = useCallback(
() => {
if (isDisabled) return;
setIsExpanded((prevState) => !prevState);
},
[isDisabled],
);
useEffect(() => {
if (isExpanded) {
window.document.addEventListener('click', toggleMenu, false);
}
return () => {
window.document.removeEventListener('click', toggleMenu, false);
};
}, [isExpanded]);
return (
<div>
<div class="button" onClick={toggleMenu}>
<Icon name="options" />
</div>
{isExpanded && <ListMenu />}
</div>
);
};
The problem is, toggleMenu function is executed twice on button click - first one is correct, it's onClick button action, which changes state, but this state change executes useEffect (which adds event listener on click) and this click is executed on the same click, that triggered state change.
So, what should be correct and most "in reactjs spirit" way to fix this?
Your problem is named Event bubbling
You can use stopPropagation to fix that
const toggleMenu = useCallback(
(event) => {
event.stopPropagation();
if (isDisabled) return;
setIsExpanded((prevState) => !prevState);
},
[isDisabled],
);
I have a list of items and want to include an icon that opens a modal for a user to choose 'edit' or 'delete' the item.
And I put this code inside the ActionModal so that only clicked modal would open by comparing the ids.
The problem is, clicking outside the element work only one time and after that, nothing happens when the ellipsis button clicked. I think it's probably because the state inside ActionModal, 'modalOpen' remains false, but I'm stuck here and don't know how to handle it.
if (!isOpen.show || isOpen.id !== id || !modalOpen) return null;
const List = () => {
const [modal, setModal] = useState({ id: null, show: false });
const onDialogClick = (e) => {
setModal((prevState) => {
return { id: e.target.id, show: !prevState.show };
});
};
const journals = journals.map((journal) => (
<StyledList key={journal.id}>
<Option>
<FontAwesomeIcon
icon={faEllipsisV}
id={journal.id}
onClick={onDialogClick}
/>
<ActionModal
actions={['edit', 'delete']}
id={journal.id}
isOpen={modal}
></ActionModal>
</Option>
const ActionModal = ({ id, actions, isOpen }) => {
const content = actions.map((action) => <li key={action}>{action}</li>);
const ref = useRef();
const [modalOpen, setModalOpen] = useState(true);
useOnClickOutside(ref, () => setModalOpen(!modalOpen));
if (!isOpen.show || isOpen.id !== id || !modalOpen) return null;
return (
<StyledDiv>
<ul ref={ref}>{content}</ul>
</StyledDiv>
);
};
function useOnClickOutside(ref, handler) {
useEffect(() => {
const listener = (event) => {
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);
};
}, [ref, handler]);
}
So fistly you do not need to include ref and handler as dependencies in useEffect hook, because evvent listeners are set on initial load, there is no need to set it on every value change.
I think I don't fully understand your situation. So you need to close the modal after it is opened by pressing outside it? or you want to be able to press that 3 dots icon when it's opened?
P.S.
I little bit condesed your code. Try this and let me know what is happening. :)
const ActionModal = ({ id, actions, isOpen, setOpen }) => {
const ref = useRef();
const content = actions.map((action) => <li key={action}>{action}</li>);
useEffect(() => {
const listener = (event) => {
if (ref.current || !ref.current.contains(event.target)) {
setOpen({...open, show: false});
}
};
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, []);
return (
<StyledDiv>
<div ref={ref}>
<ul>{content}</ul>
</div>
</StyledDiv>
);
};
This code has worked for me before but i'm not sure what's changed in this other component i'm trying to use it in.
I've tried using hooks to open and close modal and just plain on click event listener but both times it closes on clicking anywhere on the page.
componentDidMount() {
document.addEventListener('click', this.handleOutsideClick);
}
componentWillUnmount() {
document.removeEventListener('click', this.handleOutsideClick);
}
handleOutsideClick = (e) => {
if (this.state.showInfoModal && !this.node.contains(e.target)) this.handleInfoToggle();
console.log(this.state.showInfoModal, e.target, this.node, 'clicked outside');
}
handleInfoToggle = (event) => {
const { showInfoModal } = this.state;
if (event) event.preventDefault();
this.setState({ showInfoModal: !showInfoModal });
};
renderSomething = (args) => {
return(
<span ref={(node) => { this.node = node; }}>
{something === true && <span className={styles.somethingelse}>
<HintIcon onClick={this.handleInfoToggle} /></span>}
<Modal visible={showInfoModal} onCancel={this.handleInfoToggle}>
some information to show
</Modal>
</span>
)
}
render() => {
return (
{this.renderSomething(args)}
)
}
Not sure if this is enough info. but this is driving me nuts.
I also tried adding a dontCloseModal function that someone had suggested:
dontCloseModal = (e) => {
e.stopPropagation();
console.log(e);
this.setState({
showInfoModal: true
});
}
<div onClick={this.dontCloseModal}></div>
(((this would go around the <Modal/> component )))
const refs = React.createRef(); // Setup to wrap one child
const handleClick = (event) => {
const isOutside = () => {
return !refs.current.contains(event.target);
};
if (isOutside) {
onClick();
}
};
useEffect(() => {
document.addEventListener('click', handleClick);
return function() {
document.removeEventListener('click', handleClick);
};
});
return (element, idx) => React.cloneElement(element, { ref: refs[idx] });
}
export default ClickOutside;
Tried using a component like this ^^ and adding <ClickOutside onClick={this.closeInfoModal()}></ClickOutside>
But same issue with this too- closes on click anywhere including inside modal
After playing with this a little bit, it seems that you should also useRef here.
This will allow you to control toggling the modal if the user clicks outside and inside the modal's target.
There are a lot of sophisticated ways to achieve this. However, since we are dealing with hooks here, it would be best to use a custom hook.
Introducing useOnClick đź’«:
// Custom hook for controling user clicks inside & outside
function useOnClick(ref, handler) {
useEffect(() => {
const listener = event => {
// Inner Click: Do nothing if clicking ref's element or descendent elements, similar to the solution I gave in my comment stackoverflow.com/a/54633645/4490712
if (!ref.current || ref.current.contains(event.target)) {
return;
}
// Outer Click: Do nothing if clicking wrapper ref
if (this.wrapperRef && !this.wrapperRef.contains(event.target)) {
return;
}
handler(event);
};
// Here we are subscribing our listener to the document
document.addEventListener("mousedown", listener);
return () => {
// And unsubscribing it when we are no longer showing this component
document.removeEventListener("mousedown", listener);
};
}, []); // Empty array ensures that effect is only run on mount and unmount
}
Watch this Demo in CodeSandBox so you can see how this is implemented using hooks.
Welcome to StackOverflow!
Using functional components and Hooks in React, I'm having trouble moving focus to newly added elements. The shortest way to see this is probably the following component,
function Todos (props) {
const addButton = React.useRef(null)
const [todos, setTodos] = React.useState(Immutable.List([]))
const addTodo = e => {
setTodos(todos.push('Todo text...'))
// AFTER THE TODO IS ADDED HERE IS WHERE I'D LIKE TO
// THROW THE FOCUS TO THE <LI> CONTAINING THE NEW TODO
// THIS WAY A KEYBOARD USER CAN CHOOSE WHAT TO DO WITH
// THE NEWLY ADDED TODO
}
const updateTodo = (index, value) => {
setTodos(todos.set(index, value))
}
const removeTodo = index => {
setTodos(todos.delete(index))
addButton.current.focus()
}
return <div>
<button ref={addButton} onClick={addTodo}>Add todo</button>
<ul>
{todos.map((todo, index) => (
<li tabIndex="0" aria-label={`Todo ${index+1} of ${todos.size}`}>
<input type="text" value={todos[index]} onChange={e => updateTodo(index, e.target.value)}/>
<a onClick={e => removeTodo(index)} href="#">Delete todo</a>
</li>
))}
</ul>
</div>
}
ReactDOM.render(React.createElement(Todos, {}), document.getElementById('app'))
FYI, todos.map realistically would render a Todo component that has the ability to be selected, move up and down with a keyboard, etc… That is why I'm trying to focus the <li> and not the input within (which I realize could be done with the autoFocus attribute.
Ideally, I would be able to call setTodos and then immediately call .focus() on the new todo, but that's not possible because the new todo doesn't exist in the DOM yet because the render hasn't happened.
I think I can work around this by tracking focus via state but that would require capturing onFocus and onBlur and keeping a state variable up to date. This seems risky because focus can move so wildly with a keyboard, mouse, tap, switch, joystick, etc… The window could lose focus…
Use a useEffect that subscribes to updates for todos and will set the focus once that happens.
example:
useEffect(() => {
addButton.current.focus()
}, [todos])
UPDATED ANSWER:
So, you only had a ref on the button. This doesn't give you access to the todo itself to focus it, just the addButton. I've added a currentTodo ref and it will be assigned to the last todo by default. This is just for the default rendering of having one todo and focusing the most recently added one. You'll need to figure out a way to focus the input if you want it for just a delete.
ref={index === todos.length -1 ? currentTodo : null} will assign the ref to the last item in the index, otherwise the ref is null
import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
function Todos(props) {
const currentTodo = React.useRef(null)
const addButton = React.useRef(null)
const [todos, setTodos] = useState([])
useEffect(() => {
const newTodos = [...todos];
newTodos.push('Todo text...');
setTodos(newTodos);
// event listener for click
document.addEventListener('mousedown', handleClick);
// removal of event listener on unmount
return () => {
document.removeEventListener('mousedown', handleClick);
};
}, []);
const handleClick = event => {
// if there's a currentTodo and a current addButton ref
if(currentTodo.current && addButton.current){
// if the event target was the addButton ref (they clicked addTodo)
if(event.target === addButton.current) {
// select the last todo (presumably the latest)
currentTodo.current.querySelector('input').select();
}
}
}
const addTodo = e => {
const newTodo = [...todos];
newTodo.push('New text...');
setTodos(newTodo);
}
// this is for if you wanted to focus the last on every state change
// useEffect(() => {
// // if the currentTodo ref is set
// if(currentTodo.current) {
// console.log('input', currentTodo.current.querySelector('input'));
// currentTodo.current.querySelector('input').select();
// }
// }, [todos])
const updateTodo = (index, value) => {
setTodos(todos.set(index, value))
}
const removeTodo = index => {
setTodos(todos.delete(index))
currentTodo.current.focus()
}
return <div>
<button onClick={addTodo} ref={addButton}>Add todo</button>
<ul>
{todos.length > 0 && todos.map((todo, index) => (
<li tabIndex="0" aria-label={`Todo ${index + 1} of ${todos.length}`} key={index} ref={index === todos.length -1 ? currentTodo : null}>
<input type="text" value={todo} onChange={e => updateTodo(index, e.target.value)} />
<a onClick={e => removeTodo(index)} href="#">Delete todo</a>
</li>
))}
</ul>
</div>
}
ReactDOM.render(React.createElement(Todos, {}), document.getElementById('root'))
Just simply wrap the focus() call in a setTimeout
setTimeout(() => {
addButton.current.focus()
})