How to set useRef for html element manually after mounting - reactjs

For context, I have a react app with a tree explorer. When the user hovers a node, I show a menu for 5 seconds using a setTimeout inside a useEffect. I want to be able to set a ref on a file input element in the menu but the ref is always null. How can I update the ref after it has been mounted? The issue it seems is that the input is not mounted until the tree node is hovered but the component the input node is created in has already been mounted.
------------- EDIT -----------
The following code snippet works. The key seems to be the e.stopPropagation(). I thought a ref would have been a better choice but it did not seem to work.
// component
const [showControls, setShowControls] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
setShowControls(false);
}, 5000);
return () => clearTimeout(timer);
}, [showControls]);
const handleChange = (e) => {
console.log('handleChange...');
}
const onButtonClick = (e) => {
e.stopPropagation();
e.preventDefault();
const el = document.querySelector("input[type=file]");
el.click();
};
// some other code...
return (
//...other jsx
{ showControls && (
<>
<IconButton onClick={onButtonClick} />
<input
id="hiddenFileInput"
type="file"
onClick={(e) => e.stopPropagation()}
onChange={handleChange}
style={{display: 'none'}}
/>
</>
)}
);

Related

Why is my component ref always undefined during test

I have a container element that renders a bunch of button components within it. One of the requirements I'm trying to implement is that in case only one button is rendered, I want it to be disabled. Now, the logic that determines how many buttons will be rendered within the container is quite complicated so I can't just check the length of a list to determine that.
So I thought I would be creative and use a ref to check how many children the container has to determine whether the button inside should be disabled:
simplified code snippet:
import React, { useRef } from 'react';
const Component = () => {
const containerRef = useRef();
const isDisabled = !containerRef.current || ref.current.children.length < 2;
return (
<div ref={containerRef}>
<h3>Title</h3>
{roster.map((category) =>
category.positions.every((position) => position.isSelected) ? (
<Button disabled={isDisabled} {...otherProps} />
) : (
category.positions.map(
(position) =>
position.isSelected && (
<Button disabled={isDisabled} {...otherProps} />
)
)
)
)}
</div>
);
};
The above code works in my app but the problem is that when I'm trying to test this component, ref.current is always undefined which prevents me from testing the case where I have more than one button rendered in the container and that they are NOT disabled.
My test:
it('calls handleClick when a button is clicked', async () => {
const { user } = render(
<Component {...defaultProps} rosterPositionsConfig={config}/>
);
const firstButton = screen.getAllByRole('button')[0];
await user.click(firstButton );
expect(defaultProps.handleClick).toHaveBeenCalledTimes(1); <-- assertion failing
});
The first render of this component will always have undefined for containerRef.current, because the element on the page can't exist until after you've rendered.
So actually, the only reason your code is "working" is that your component is rendering twice (or more). The first render always has no ref and thus sets isDisabled to true, and the second one does have the ref and calculates isDisabled from that. I would guess that it's rendering twice due to <React.StrictMode>, in which case your code will stop working when you do a production build (strict mode causes a double render in dev builds only). In the test environment, you only render once, so this accidental double rendering goes away, and the bug becomes more apparent.
I recommend that you fix this by not using a ref. Instead, you can count the number of children from the data directly. There's a few ways this logic could look, but here's one:
const Component = () => {
const containerRef = useRef();
let count = 0;
for (const category of roster) {
if (category.positions.every((position) => position.isSelected)) {
count += 1;
} else {
count += category.positions.filter(
(position) => position.isSelected
).length;
}
}
const isDisabled = count <= 1;
return (
<div>
<h3>Title</h3>
{roster.map((category) =>
category.positions.every((position) => position.isSelected) ? (
<Button disabled={isDisabled} {...otherProps} />
) : (
category.positions.map(
(position) =>
position.isSelected && (
<Button disabled={isDisabled} {...otherProps} />
)
)
)
)}
</div>
);
};
If you don't like the fact that this is basically writing the same looping logic twice (i'm not a fan of that either), here's another way you might do it. You create an array of buttons assuming that they will not be disabled, but then if the length is 1, you use cloneElement to edit that JSX element:
import { cloneElement } from "react";
const Component = () => {
const containerRef = useRef();
const isDisabled = !containerRef.current || ref.current.children.length < 2;
// I'm building the array this way so that it's just a 1-d array, and it
// doesn't have any `false`s in it
const buttons = [];
roster.forEach((category) => {
if (category.positions.every((position) => position.isSelected)) {
buttons.push(<Button disabled={false} {...otherProps} />);
} else {
category.positions.forEach((position) => {
if (position.isSelected) {
buttons.push(<Button disabled={false} {...otherProps} />);
}
});
}
});
if (buttons.length === 1) {
buttons[0] = cloneElement(buttons[0], { disabled: true });
}
return (
<div ref={containerRef}>
<h3>Title</h3>
{buttons}
</div>
);
};
If you absolutely had to use a ref (which, again, i do not recommend for this case), you would need to wait until after the render is complete for the ref to be updated, then count the children, then set state to force a second render. You would probably need to use a layout effect so that this double render doesn't cause a flicker to the user:
const Component = () => {
const containerRef = useRef();
const [disabled, setDisabled] = useState(true);
useLayoutEffect(() => {
setDisabled(containerRef.current.children.length < 2);
});
return (
<div ref={containerRef}>
<h3>Title</h3>
{roster.map((category) =>
category.positions.every((position) => position.isSelected) ? (
<Button disabled={isDisabled} {...otherProps} />
) : (
category.positions.map(
(position) =>
position.isSelected && (
<Button disabled={isDisabled} {...otherProps} />
)
)
)
)}
</div>
);
};

Checkbox is not working properly with React state

I am building a form where the hotel owners will add a hotel and select a few amenities of the same hotel. The problem is If I use state in the onChange function the checkbox tick is not displayed. I don't know where I made a mistake?
import React from "react";
import { nanoid } from "nanoid";
const ListAmenities = ({
amenities,
className,
setHotelAmenities,
hotelAmenities,
}) => {
const handleChange = (e) => {
const inputValue = e.target.dataset.amenitieName;
if (hotelAmenities.includes(inputValue) === true) {
const updatedAmenities = hotelAmenities.filter(
(amenitie) => amenitie !== inputValue
);
setHotelAmenities(updatedAmenities);
} else {
//If I remove this second setState then everything works perfectly.
setHotelAmenities((prevAmenities) => {
return [...prevAmenities, inputValue];
});
}
};
return amenities.map((item) => {
return (
<div className={className} key={nanoid()}>
<input
onChange={handleChange}
className="mr-2"
type="checkbox"
name={item}
id={item}
data-amenitie-name={item}
/>
<label htmlFor={item}>{item}</label>
</div>
);
});
};
export default ListAmenities;
The problem is that you are using key={nanoid()}. Instead, using key={item] should solve your probem.
I believe your application that uses ListAmenities is something like this:
const App = () => {
const [hotelAmenities, setHotelAmenities] = useState([]);
return (
<ListAmenities
amenities={["A", "B", "C"]}
className="test"
setHotelAmenities={setHotelAmenities}
hotelAmenities={hotelAmenities}
/>
);
};
In your current implementation, when handleChange calls setHotelAmenities it changed hotelAmenities which is a prop of ListAmenities and causes the ListAmenities to rerender. Since you use key={nanoid()} react assumes that a new item has been added and the old one has been removed. So it re-renders the checkbox. Since there is no default value of checkbox, it is assumed that it is in unchecked state when it is re-rendered.

Click event added in useEffect after changing state by onClick is executed in same moment

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],
);

React state not correct when function called via addEventListener

I have a React component that uses state to manage a changed variable if a form input is updated.
Assuming I've made some updates to the form, the issue I'm having is if I dispatch a click event to the onCancel function using addEventListener the value of changed is not correct but if I call onCancel from the JSX the value is correct.
Any ideas?
const Edit = (props) => {
let [changed, setChanged] = useState(false);
// Fired when a form value is updated
const onChange = (e) => {
setChanged("true");
};
// Close modal
const onCancel = () => {
console.log(changed); // This will be false when triggered from addEventListener
};
useEffect(() => {
let form = document.querySelector(".oda-edit-form");
// Close Window on click outside
form.addEventListener("click", function () {
onCancel();
});
}, []);
return (
<div>
<input type="text" onChange={(e) => onChange(e)} />
<button onClick={onCancel}>Close</button>
</div>
);
};
You need to re render your component as soon your state changes to run the onCancel() funtion.
let form = document.querySelector(".oda-edit-form");
// Close Window on click outside
form.addEventListener("click", function () {
onCancel();
});
}, [changed]); // < ----- add dependancy
Removed the addEventListener and added an onClick directly to the JSX with a custom method.
const Edit = (props) => {
let [changed, setChanged] = useState(false);
// Fired when a form value is updated
const onChange = (e) => {
setChanged("true");
};
// Close modal
const onCancel = () => {
console.log(changed); // This will be false when triggered from addEventListener
};
const onClickOutside = (e) => {
let element = e.target.querySelector('.wide-card');
let isClickInside = element.contains(event.target);
// // Close Modal
if (!isClickInside) {
onCancel();
}
};
return (
<div onClick-{(e)=>onClickOutside(e)}>
<input type="text" onChange={(e) => onChange(e)} />
<button onClick={onCancel}>Close</button>
</div>
);
};

How to set focus after React state update/render

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

Resources