Prevent infinite loop in useEffect when setting state - javascript

I am currently building a scheduling app. If a user selects two dates, I am attempting to select all date blocks between the two selected dates in the calendar as well. I am able to achieve this, but it causes my useEffect to fire into an infinite loop because I have state as a dependency in my useEffect where I am setting state. I am unsure of the best method to prevent the infinite loop behavior. The useEffect in question is the bottom one. My code is as follows:
export default function App() {
const [selectedDate, handleDateChange] = useState(
dayjs().format("YYYY-MM-DD")
);
const [events] = useState([
{
id: "5e24d1fa-aa66-4122-b1eb-97792f0893b0",
name: "Rodriquez Family",
selectedDates: ["2021-05-01"],
status: "submitted"
},
{
id: "269a0381-63c7-4ab6-92d8-7f7b836aee6f",
name: "Test Family",
selectedDates: ["2021-05-03"],
status: "submitted"
}
]);
const [data, setData] = useState([]);
const getDaysArray = async (firstDay, lastDay) => {
let dates = [];
var dow = dayjs(firstDay).day();
while (dow > 0) {
dates.push(null);
dow = dow - 1;
}
while (firstDay <= lastDay) {
dates.push(firstDay);
firstDay = dayjs(firstDay).add(1, "days").format("YYYY-MM-DD");
}
return dates;
};
useEffect(() => {
const getDates = async () => {
const firstDay = dayjs(selectedDate)
.startOf("month")
.format("YYYY-MM-DD");
const lastDay = dayjs(firstDay).endOf("month").format("YYYY-MM-DD");
const dates = await getDaysArray(firstDay, lastDay);
const list = dates.map((date) => {
const event = events.find(({ selectedDates = [] }) =>
selectedDates.includes(date)
);
return event ? { date, event } : { date, event: null, checked: false };
});
setData(list);
};
getDates();
}, [events, selectedDate]);
const selectDate = (date) => {
setData(
(a) =>
a &&
a.map((item) =>
item.date === date ? { ...item, checked: !item.checked } : item
)
);
};
useEffect(() => {
if (data && data.filter((res) => res.checked).length > 1) {
const filterDates = data.filter((r) => r.checked);
const startDate = filterDates[0].date;
const endDate = filterDates[filterDates.length - 1].date;
const datesToUpdate = data.filter(
(res) => res.date > startDate && res.date < endDate
);
const newArr = data.map((date) => {
const updateCheck = datesToUpdate.find((r) => r.date === date.date);
return updateCheck ? { ...updateCheck, checked: true } : date;
});
setData(newArr);
}
}, [data]);
return (
<MuiPickersUtilsProvider utils={DayJsUtils}>
<div className="App">
<DatePicker
minDate={dayjs()}
variant="inline"
openTo="year"
views={["year", "month"]}
label="Year and Month"
helperText="Start from year selection"
value={selectedDate}
onChange={handleDateChange}
/>
</div>
<div className="cal">
<div className="cal-div1"></div>
<div className="cal-div2 "></div>
<div className="cal-div3 cal-cir-hov"></div>
<div className="cal-div4"> SUN </div>
<div className="cal-div5"> MON </div>
<div className="cal-div6"> TUE </div>
<div className="cal-div7"> WED </div>
<div className="cal-div8"> THU </div>
<div className="cal-div9"> FRI </div>
<div className="cal-div10"> SAT </div>
{data &&
data.map((r, i) => {
return (
<>
<div
onClick={() =>
!r.checked &&
r.date >= dayjs().format("YYYY-MM-DD") &&
!r.event &&
selectDate(r.date)
}
style={
r.checked
? { backgroundColor: "green" }
: { color: "#565254" }
}
key={i}
className="cal-cir-hov"
>
<div>{r.date} </div>
<div
style={
r.event?.status === "submitted"
? { color: "orange" }
: { color: "green" }
}
>
{r.event?.name}
</div>
</div>
</>
);
})}
</div>
</MuiPickersUtilsProvider>
);
}
attached is a code sandbox for debugging and to show the behavior I am currently talking about. Select two separate dates that are greater than today and you will see all the dates in between are selected, but the app goes into a loop https://codesandbox.io/s/dawn-snow-03r59?file=/src/App.js:301-4499

If your useEffect depends on a variable that you're updating on the same useEffect there will always be the re-render and cause a loop.
If you want it to execute only once, you should remove the data variable from the useEffect dependency array.
But if you really wanna mutate the state every time that the data variable changes, my recommendation is to create another state for the mutated data.
For example setFormattedData would not change the data itself, but you would still have a state for this data in the format that you want.

Related

What should i do to change select options after changeing previous input without one step gap? react

I have an input with label "Ilosc osob". When I change it, I want to change the Select's options, depends of number in input. It happens, but with one step gap. What should I do?
matchedTables depends on props.tables, and it is filtered array from parent component.
const ReservationForm = (props) => {
const [enteredSize, setEnteredSize] = useState("");
const [enteredTable, setEnteredTable] = useState("");
const [enteredDate, setEnteredDate] = useState("");
const [enteredTime, setEnteredTime] = useState("");
const [sizeMatchedTables, setSizeMatchedTables] = useState([
{ id: 55, table_size: 1, isBusy: false },
{ id: 56, table_size: 2, isBusy: true },
]);
//some code
const matchingSizeTablesHandler = () => {
const newArray = props.tables.filter((tables) => {
if (tables.table_size >= enteredSize) {
return tables;
}
});
setSizeMatchedTables(newArray);
};
const sizeChangeHandler = (event) => {
setEnteredSize(event.target.value);
matchingSizeTablesHandler();
};
//some code
return(
<div>
<div className="new-reservation__control">
<label>Ilość osób</label>
<input
type="number"
min={1}
max={10}
value={enteredSize}
onChange={sizeChangeHandler}
/>
</div>
<select
className="new-reservation__control"
value={enteredTable}
onChange={tableChangeHandler}
>
<TablesInSelect passedOptions={sizeMatchedTables} />
</select>
</div>
)};
const TablesInSelect = (props) => {
return (
<>
{props.passedOptions.map((option, index) => {
return (
<option key={index} value={option.id}>
{option.id}
</option>
);
})}
</>
);
};
I found workaround this problem, but dont think its best way to do this. I changed matchingSizeTableHandler so it work with argument (and also change filter to reduce but it is not the case):
const matchingSizeTablesHandler = (size) => {
const newArray = props.tables.reduce((newTables, tables) => {
if (tables.table_size >= size) {
var newValue = tables;
newTables.push(newValue);
}
if (size === "") newTables = [];
return newTables;
}, []);
setSizeMatchedTables(newArray);
};
and then, in sizeChangeHandler I changed call out matchingSizeTableHandler with event.target.value parameter
const sizeChangeHandler = (event) => {
setEnteredSize(event.target.value);
matchingSizeTablesHandler(event.target.value);
};
. If someone can explain to me the other way to implement this, using the sizeMatchedTable state as a parameter in matchingSizeTablesHandler function, I would be thankful.

How to make only one button disabled on click in map method and how to change className after the timer expires in React?

How to make only one button disabled on click in map method? With the help of the disabled hook, I have all pressed buttons . And how to make it so that after the timer expires the 'current__events__hot-price disabled' className changes back to 'current__events__hot-price'?
import { useEffect, useState } from 'react'
import './CurrentEventsItem.scss'
const CurrentEventsItem = () => {
const [timeLeft, setTimeLeft] = useState(5*60)
const getPadTime = (time) => time.toString().padStart(2, '0')
const minutes = getPadTime(Math.floor(timeLeft / 60))
const seconds = getPadTime(timeLeft - minutes * 60)
useEffect(() => {
const interval = setInterval(() => {
setTimeLeft((timeLeft) => (timeLeft >= 1 ? timeLeft - 1 : setDisabled(false) || 5*60))
}, 1000)
return () => clearInterval(interval)
}, [])
const [appState, changeState] = useState({
objects: [
{id: 1, title: 'Apple iPhone 13 Pro Max 256Gb (небесно-голубой)', avatar: 'https://cdn-icons-png.flaticon.com/512/147/147144.png', statusItem: false},
{id: 2, title: '500 Stars', avatar: 'https://cdn-icons-png.flaticon.com/512/147/147144.png', statusItem: false},
{id: 3, title: 'Sony PlayStation 5 Digital Edition ', avatar: 'https://cdn-icons-png.flaticon.com/512/147/147144.png', statusItem: false}
]
})
const toggleActive = (index) => {
let arrayCopy = [...appState.objects]
arrayCopy[index].statusItem
? (arrayCopy[index].statusItem = false)
: (arrayCopy[index].statusItem = true)
setDisabled(true)
changeState({...appState, objects: arrayCopy})
}
const toggleActiveStyles = (index) => {
if (appState.objects[index].statusItem) {
return 'current__events__hot-price disabled'
} else {
return 'current__events__hot-price'
}
}
const toggleActiveStylesBtns = (index) => {
if (appState.objects[index].statusItem) {
return 'current__events__btn-green disabled'
} else {
return 'current__events__btn-green'
}
}
const [disabled, setDisabled] = useState(false)
return (
<>
<div className='current__events__wrapper'>
{appState.objects.map((item, index) =>
<div className="current__events__hot-price__item" key={index}>
<div className={toggleActiveStyles(index)}>
<h5 className="current__events__card-title__large">Hot Price</h5>
</div>
<div className="current__events__image">
<img src={item.avatar} alt='user' className="rounded-circle" width='75' height='75'/>
</div>
<div className="current__events__info">
<h4 className="current__events__title__middle">{item.title}</h4>
</div>
<div className="current__events__timer">
<span>{minutes}</span>
<span>:</span>
<span>{seconds}</span>
</div>
<button className={toggleActiveStylesBtns(index)} onClick={() => toggleActive(index)} disabled={disabled}>СДЕЛАТЬ ХОД</button>
</div>
)}
</div>
</>
)
}
export default CurrentEventsItem
From what I can gather, you toggle one element's status by index and disable the button at that specific index. Instead of toggling the statusItem property (a state mutation, BTW) you should just store the index of the "active" item.
Add an activeIndex state that is null in its "inactive" state, and equal to an index when it is "active". The useEffect hook should just instantiate the interval and the interval callback should just decrement the time left. Use a separate useEffect hook with a dependency on the timeLeft state to reset the activeIndex and timeLeft states. Disable the button element when the activeIndex equals the mapped index. Pass the activeIndex state to the CSS classname utility functions.
Example:
const CurrentEventsItem = () => {
const [activeIndex, setActiveIndex] = useState(null);
const [timeLeft, setTimeLeft] = useState(5*60);
const getPadTime = (time) => time.toString().padStart(2, '0');
const minutes = getPadTime(Math.floor(timeLeft / 60));
const seconds = getPadTime(timeLeft - minutes * 60);
useEffect(() => {
const interval = setInterval(() => {
setTimeLeft((timeLeft) => timeLeft - 1);
}, 1000);
return () => clearInterval(interval);
}, []);
const [appState, changeState] = useState({
objects: [
{id: 1, title: 'Apple iPhone 13 Pro Max 256Gb (небесно-голубой)', avatar: 'https://cdn-icons-png.flaticon.com/512/147/147144.png', statusItem: false},
{id: 2, title: '500 Stars', avatar: 'https://cdn-icons-png.flaticon.com/512/147/147144.png', statusItem: false},
{id: 3, title: 'Sony PlayStation 5 Digital Edition ', avatar: 'https://cdn-icons-png.flaticon.com/512/147/147144.png', statusItem: false}
]
});
const toggleActive = (index) => {
setActiveIndex(index);
};
useEffect(() => {
if (timeLeft <= 0) {
setActiveIndex(null);
setTimeLeft(5 * 60);
}
}, [setActiveIndex, setTimeLeft, timeLeft]);
const toggleActiveStyles = (index) => {
if (index === activeIndex) {
return 'current__events__hot-price disabled';
} else {
return 'current__events__hot-price';
}
};
const toggleActiveStylesBtns = (index) => {
if (index === activeIndex) {
return 'current__events__btn-green disabled';
} else {
return 'current__events__btn-green';
}
};
return (
<>
<div className='current__events__wrapper'>
{appState.objects.map((item, index) =>
<div className="current__events__hot-price__item" key={index}>
<div className={toggleActiveStyles(activeIndex)}>
<h5 className="current__events__card-title__large">Hot Price</h5>
</div>
<div className="current__events__image">
<img src={item.avatar} alt='user' className="rounded-circle" width='75' height='75'/>
</div>
<div className="current__events__info">
<h4 className="current__events__title__middle">{item.title}</h4>
</div>
<div className="current__events__timer">
<span>{minutes}</span>
<span>:</span>
<span>{seconds}</span>
</div>
<button
className={toggleActiveStylesBtns(activeIndex)}
onClick={() => toggleActive(index)}
disabled={activeIndex === index}
>
СДЕЛАТЬ ХОД
</button>
</div>
)}
</div>
</>
);
}
export default CurrentEventsItem

React: updating a state resets another state

I am doing my first real React app and I ran into a bug I can't seem to fix.
I have a card component somewhere in my app that, when it gets clicked it maximizes to take up a bigger section of the screen. It is called in MainSelectionButton.js as follows:
export default function MainSelectionButtons(props) {
const [maximizeCard, setMaximizeCard] = useState('nothing')
if (maximizeCard === 'nothing') {
return <ImageButton
title={props.title}
icon={props.icon}
whatIsMaximized={setMaximizeCard}
/>
}
else return <MaximizedPlot
title={maximizeCard}
whatIsMaximized={setMaximizeCard}
cryptoData={props.cryptoData}
daysChangeHandler={props.daysChangeHandler}
days={props.days}
/>
}
MaximizedPlot looks like this:
export default function MaximizedPlot(props) {
const sorter = (item) => {
switch (item) {
case 'Strategy Plot':
return strategyPlot;
case 'Value Plot':
return valuePlot
case 'Relative Plot':
return relativePlot;
default: break
}
}
const daysChangeHandler = (newDays) => {
props.daysChangeHandler(newDays)
}
const xButtonHandler = () => {
props.whatIsMaximized('nothing')
}
const title = (props.days === 0) ? 'the maximal number of days' : props.days
return <Card backgroundColor='#192734' isMaximized='maximized' >
<XButton onClick={() => xButtonHandler()}>x</XButton>
<h1>{props.title + ' over ' + title}</h1>
<img src={sorter(props.title)} width='100%' alt=''></img>
<NumberOfDays daysChangeHandler={daysChangeHandler}
cryptoData={props.cryptoData}>
</NumberOfDays>
</Card>
}
This component looks like this:
Maximized card component
With the XButton component on the top right corner and the NumberOfDays component being the input field at the bottom of the graph.
export default function App() {
const [cryptoData, setCryptoData] = useState({ timestamp: [], price: [], long_ma: [], short_ma: [], money: [], action: [] })
const [hours, setHours] = useState(744)
useEffect(() => {
getData(30 * 24).then(response => setCryptoData(response))
}, []);
const daysChangeHandler = (days) => {
setHours(days * 24, getData(hours).then(response => setCryptoData(response)))
console.log('days Changed!! new:', days)
}
return (
<div className="App">
<Card isMaximized=''>
<MainSelectionButtons title={'Strategy Plot'} icon={iconStrategy} cryptoData={cryptoData} daysChangeHandler={daysChangeHandler} days={hours / 24}></MainSelectionButtons>
<MainSelectionButtons title={'Value Plot'} icon={iconValue} cryptoData={cryptoData} daysChangeHandler={daysChangeHandler} days={hours / 24}></MainSelectionButtons>
<MainSelectionButtons title={'Relative Plot'} icon={iconRelative} cryptoData={cryptoData} daysChangeHandler={daysChangeHandler} days={hours / 24}></MainSelectionButtons>
</Card>
<Card>
is running
</Card>
</div>
);
}
EDIT: the user input component, called NumberOfDays looks like this:
export default function NumberOfDays(props) {
const [typedDays, setTypedDays] = useState(30)
const inputDaysChangeHandler = (event) => {
setTypedDays(event.target.value)
}
const submitHandler = () => {
props.daysChangeHandler(typedDays)
}
const predefinedDaysHandler = (days) => {
props.daysChangeHandler(days)
}
return <div>
<div className='input-form'>
Enter number of days to display:
<input type='number'
onChange={inputDaysChangeHandler}
className='days-number-input' />
<input
type='submit'
className='submit-button'
onClick={() => submitHandler()} />
or:
<button className='submit-button' onClick={() => predefinedDaysHandler(0)}>Max</button>
<button className='submit-button' onClick={() => predefinedDaysHandler(30)}>30 days</button>
<button className='submit-button' onClick={() => predefinedDaysHandler(240)} >10 days</button>
</div>
</div>
}
THE PROBLEM
Whenever I click on a button in the user input field (so 'submit', 'max', etc.) the window gets minimized. I can't figure out why, it just closes every time. What am I doing wrong? Is there something fundamentally wrong with my code or is it a little logical mistake that I am missing?
Thank you in advance for the help, I spent already more time on it than I'd like to admit.
UPDATE:
If anyone has this problem, It is solved if instead of using props, you use context.

Update localStorage Value on submit in Typescript and React

I want to update values within an Array of Objects saved to my localStorage.
The Objects are already in my localStorage. I want to update the progress.
The key is skills and the value is the Array.
I have a form with two sliders on my page which tracks the time(progress). On Submit I want to update the progress.
I think I have a major misunderstanding of how this works because I don't get it to work.
[
{category: "crafting"
description: "Prepare yourself for cold times"
imageSrc: "https://images.unsplash.com/photo-1621490153925-439fbe544722?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=1100&q=80"
isDone: false
progress: 500
title: "Knitting"},
{category: "mental"
description: "Take control over your mind"
imageSrc: "https://images.unsplash.com/photo-1554244933-d876deb6b2ff?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=1100&q=80"
isDone: false
progress: 500
title: "Meditation"}]
This is my handleSubmit function which updates the Value. Its controlled by a form with 2 Sliders (mins) (hrs)
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const hrs = parseInt(hours);
const mins = parseInt(minutes);
const addedHours = value + hrs + mins * 0.015;
setValue(addedHours);
}
Here is a screenshot of my localStorage:
localStorage
Here is the whole code of the page:
export default function DetailPage(): JSX.Element {
const [hours, setHours] = useState('0');
const [minutes, setMinutes] = useState('0');
const [value, setValue] = useState(0);
const history = useHistory();
const { skills } = useLocalStorageSkills();
const { title }: { title: string } = useParams();
const filteredSkills = skills.filter(
(skills) => skills.title === String(title)
);
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const hrs = parseInt(hours);
const mins = parseInt(minutes);
const addedHours = value + hrs + mins * 0.015;
setValue(addedHours);
}
const ranking =
value === 0
? '0'
: value < 99
? '1'
: value > 100 && value < 199
? '2'
: value > 200 && value < 299
? '3'
: value > 300 && value < 399
? '4'
: '5';
const ranktrack = JSON.stringify(ranking);
localStorage.setItem('ranking', ranking);
const ranktrackparsed = JSON.parse(ranktrack);
localStorage.getItem('ranking');
return (
<div className={styles.container}>
{filteredSkills.map((skills) => (
<Header
{...skills}
key={skills.title}
title={skills.title}
type="detail"
imageSrc={skills.imageSrc}
onClick={() => {
history.push('/');
}}
/>
))}
<main className={styles.main}>
{filteredSkills.map((skills) => (
<ProgressTrack
value={(skills.progress - value).toFixed(1)}
rank={ranktrackparsed}
/>
))}
<form className={styles.form} onSubmit={handleSubmit}>
<Rangeslider
size="hours"
value={hours}
min={'0'}
max={'24'}
onChange={(event) => setHours(event.target.value)}
/>
<Rangeslider
size="minutes"
value={minutes}
min={'0'}
max={'59'}
onChange={(event) => setMinutes(event.target.value)}
/>
<ActionButton
children={'Submit'}
type={'submit'}
style="primary"
></ActionButton>
</form>
{filteredSkills.map((skills) => (
<ProgressBar
percentageVal={value}
textVal={value.toFixed(1)}
minValue={1}
maxValue={500}
children={skills.description}
/>
))}
</main>
<Navigation activeLink={'add'} />
</div>
);
}
Here I have a custom hook, to use localStorage:
import useLocalStorage from './useLocalStorage';
import type { Skill } from '../../types';
export default function useLocalStorageSkills(): {
skills: Skill[];
addSkill: (skills: Skill) => void;
removeSkill: (newSkill: Skill) => void;
editSkill: (oldSkill: Skill, newSkill: Skill) => void;
} {
const [skills, setSkills] = useLocalStorage<Skill[]>('skills', []);
function addSkill(skill: Skill) {
setSkills([...skills, skill]);
}
function removeSkill(newSkill: Skill) {
setSkills(skills.filter((skill) => skill !== newSkill));
}
function editSkill(deleteSkill: Skill, newSkill: Skill) {
setSkills([
...skills.filter((skill) => skill.title !== deleteSkill.title),
newSkill,
]);
}
return { skills, addSkill, removeSkill, editSkill };
}
Its my first question, If you need more information, I will do my best to provide more.

React: How to navigate through list by arrow keys

I have build a simple component with a single text input and below of that a list (using semantic ui).
Now I would like to use the arrow keys to navigate through the list.
First of all I have to select the first element. But how do I access a specific list element?
Second I would get the information of the current selected element and select the next element. How do I get the info which element is selected?
Selection would mean to add the class active to the item or is there a better idea for that?
export default class Example extends Component {
constructor(props) {
super(props)
this.handleChange = this.handleChange.bind(this)
this.state = { result: [] }
}
handleChange(event) {
// arrow up/down button should select next/previous list element
}
render() {
return (
<Container>
<Input onChange={ this.handleChange }/>
<List>
{
result.map(i => {
return (
<List.Item key={ i._id } >
<span>{ i.title }</span>
</List.Item>
)
})
}
</List>
</Container>
)
}
}
Try something like this:
export default class Example extends Component {
constructor(props) {
super(props)
this.handleKeyDown = this.handleKeyDown.bind(this)
this.state = {
cursor: 0,
result: []
}
}
handleKeyDown(e) {
const { cursor, result } = this.state
// arrow up/down button should select next/previous list element
if (e.keyCode === 38 && cursor > 0) {
this.setState( prevState => ({
cursor: prevState.cursor - 1
}))
} else if (e.keyCode === 40 && cursor < result.length - 1) {
this.setState( prevState => ({
cursor: prevState.cursor + 1
}))
}
}
render() {
const { cursor } = this.state
return (
<Container>
<Input onKeyDown={ this.handleKeyDown }/>
<List>
{
result.map((item, i) => (
<List.Item
key={ item._id }
className={cursor === i ? 'active' : null}
>
<span>{ item.title }</span>
</List.Item>
))
}
</List>
</Container>
)
}
}
The cursor keeps track of your position in the list, so when the user presses the up or down arrow key you decrement/increment the cursor accordingly. The cursor should coincide with the array indices.
You probably want onKeyDown for watching the arrow keys instead of onChange, so you don't have a delay or mess with your standard input editing behavior.
In your render loop you just check the index against the cursor to see which one is active.
If you are filtering the result set based on the input from the field, you can just reset your cursor to zero anytime you filter the set so you can always keep the behavior consistent.
The accepted answer was very useful to me thanks! I adapted that solution and made a react hooks flavoured version, maybe it will be useful to someone:
import React, { useState, useEffect } from "react";
import ReactDOM from "react-dom";
import "./styles.css";
const useKeyPress = function(targetKey) {
const [keyPressed, setKeyPressed] = useState(false);
React.useEffect(() => {
const downHandler = ({ key }) => {
if (key === targetKey) {
setKeyPressed(true);
}
}
const upHandler = ({ key }) => {
if (key === targetKey) {
setKeyPressed(false);
}
};
window.addEventListener("keydown", downHandler);
window.addEventListener("keyup", upHandler);
return () => {
window.removeEventListener("keydown", downHandler);
window.removeEventListener("keyup", upHandler);
};
}, [targetKey]);
return keyPressed;
};
const items = [
{ id: 1, name: "Josh Weir" },
{ id: 2, name: "Sarah Weir" },
{ id: 3, name: "Alicia Weir" },
{ id: 4, name: "Doo Weir" },
{ id: 5, name: "Grooft Weir" }
];
const ListItem = ({ item, active, setSelected, setHovered }) => (
<div
className={`item ${active ? "active" : ""}`}
onClick={() => setSelected(item)}
onMouseEnter={() => setHovered(item)}
onMouseLeave={() => setHovered(undefined)}
>
{item.name}
</div>
);
const ListExample = () => {
const [selected, setSelected] = useState(undefined);
const downPress = useKeyPress("ArrowDown");
const upPress = useKeyPress("ArrowUp");
const enterPress = useKeyPress("Enter");
const [cursor, setCursor] = useState(0);
const [hovered, setHovered] = useState(undefined);
useEffect(() => {
if (items.length && downPress) {
setCursor(prevState =>
prevState < items.length - 1 ? prevState + 1 : prevState
);
}
}, [downPress]);
useEffect(() => {
if (items.length && upPress) {
setCursor(prevState => (prevState > 0 ? prevState - 1 : prevState));
}
}, [upPress]);
useEffect(() => {
if (items.length && enterPress) {
setSelected(items[cursor]);
}
}, [cursor, enterPress]);
useEffect(() => {
if (items.length && hovered) {
setCursor(items.indexOf(hovered));
}
}, [hovered]);
return (
<div>
<p>
<small>
Use up down keys and hit enter to select, or use the mouse
</small>
</p>
<span>Selected: {selected ? selected.name : "none"}</span>
{items.map((item, i) => (
<ListItem
key={item.id}
active={i === cursor}
item={item}
setSelected={setSelected}
setHovered={setHovered}
/>
))}
</div>
);
};
const rootElement = document.getElementById("root");
ReactDOM.render(<ListExample />, rootElement);
Attributing useKeyPress functionality to this post.
Pretty much same solution as what #joshweir provided, but in Typescript. Also instead of 'window' object I used 'ref' and added the event listeners only to the input text box.
import React, { useState, useEffect, Dispatch, SetStateAction, createRef, RefObject } from "react";
const useKeyPress = function (targetKey: string, ref: RefObject<HTMLInputElement>) {
const [keyPressed, setKeyPressed] = useState(false);
const downHandler = ({ key }: { key: string }) => {
if (key === targetKey) {
setKeyPressed(true);
}
}
const upHandler = ({ key }: { key: string }) => {
if (key === targetKey) {
setKeyPressed(false);
}
};
React.useEffect(() => {
ref.current?.addEventListener("keydown", downHandler);
ref.current?.addEventListener("keyup", upHandler);
return () => {
ref.current?.removeEventListener("keydown", downHandler);
ref.current?.removeEventListener("keyup", upHandler);
};
});
return keyPressed;
};
const items = [
{ id: 1, name: "Josh Weir" },
{ id: 2, name: "Sarah Weir" },
{ id: 3, name: "Alicia Weir" },
{ id: 4, name: "Doo Weir" },
{ id: 5, name: "Grooft Weir" }
];
const i = items[0]
type itemType = { id: number, name: string }
type ListItemType = {
item: itemType
, active: boolean
, setSelected: Dispatch<SetStateAction<SetStateAction<itemType | undefined>>>
, setHovered: Dispatch<SetStateAction<itemType | undefined>>
}
const ListItem = ({ item, active, setSelected, setHovered }: ListItemType) => (
<div
className={`item ${active ? "active" : ""}`}
onClick={() => setSelected(item)}
onMouseEnter={() => setHovered(item)}
onMouseLeave={() => setHovered(undefined)}
>
{item.name}
</div>
);
const ListExample = () => {
const searchBox = createRef<HTMLInputElement>()
const [selected, setSelected] = useState<React.SetStateAction<itemType | undefined>>(undefined);
const downPress = useKeyPress("ArrowDown", searchBox);
const upPress = useKeyPress("ArrowUp", searchBox);
const enterPress = useKeyPress("Enter", searchBox);
const [cursor, setCursor] = useState<number>(0);
const [hovered, setHovered] = useState<itemType | undefined>(undefined);
const [searchItem, setSearchItem] = useState<string>("")
const handelChange = (e: React.SyntheticEvent<HTMLInputElement>) => {
setSelected(undefined)
setSearchItem(e.currentTarget.value)
}
useEffect(() => {
if (items.length && downPress) {
setCursor(prevState =>
prevState < items.length - 1 ? prevState + 1 : prevState
);
}
}, [downPress]);
useEffect(() => {
if (items.length && upPress) {
setCursor(prevState => (prevState > 0 ? prevState - 1 : prevState));
}
}, [upPress]);
useEffect(() => {
if (items.length && enterPress || items.length && hovered) {
setSelected(items[cursor]);
}
}, [cursor, enterPress]);
useEffect(() => {
if (items.length && hovered) {
setCursor(items.indexOf(hovered));
}
}, [hovered]);
return (
<div>
<p>
<small>
Use up down keys and hit enter to select, or use the mouse
</small>
</p>
<div>
<input ref={searchBox} type="text" onChange={handelChange} value={selected ? selected.name : searchItem} />
{items.map((item, i) => (
<ListItem
key={item.id}
active={i === cursor}
item={item}
setSelected={setSelected}
setHovered={setHovered}
/>
))}
</div>
</div>
);
};
const rootElement = document.getElementById("root");
ReactDOM.render(<ListExample />, rootElement);
This is my attempt, with the downside that it requires the rendered children to pass ref correctly:
import React, { useRef, useState, cloneElement, Children, isValidElement } from "react";
export const ArrowKeyListManager: React.FC = ({ children }) => {
const [cursor, setCursor] = useState(0)
const items = useRef<HTMLElement[]>([])
const onKeyDown = (e) => {
let newCursor = 0
if (e.key === 'ArrowDown') {
newCursor = Math.min(cursor + 1, items.current.length - 1)
} else if (e.key === 'ArrowUp') {
newCursor = Math.max(0, cursor - 1)
}
setCursor(newCursor)
const node = items.current[newCursor]
node?.focus()
}
return (
<div onKeyDown={onKeyDown} {...props}>
{Children.map(children, (child, index) => {
if (isValidElement(child)) {
return cloneElement(child, {
ref: (n: HTMLElement) => {
items.current[index] = n
},
})
}
})}
</div>
)
}
Usage:
function App() {
return (
<ArrowKeyListManager>
<button onClick={() => alert('first')}>First</button>
<button onClick={() => alert('second')}>Second</button>
<button onClick={() => alert('third')}>third</button>
</ArrowKeyListManager>
);
}
It's a list with children that can be navigated by pressing the left-right & up-down key bindings.
Recipe.
Create an Array of Objects that will be used as a list using a map function on the data.
Create a useEffect and add an Eventlistener to listen for keydown actions in the window.
Create handleKeyDown function in order to configure the navigation behaviour by tracking the key that was pressed, use their keycodes fo that.
keyup: e.keyCode === 38
keydown: e.keyCode === 40
keyright: e.keyCode === 39
keyleft: e.keyCode === 37
Add State
let [activeMainMenu, setActiveMainMenu] = useState(-1);
let [activeSubMenu, setActiveSubMenu] = useState(-1);
Render by Mapping through the Array of objects
<ul ref={WrapperRef}>
{navigationItems.map((navigationItem, Mainindex) => {
return (
<li key={Mainindex}>
{activeMainMenu === Mainindex
? "active"
: navigationItem.navigationCategory}
<ul>
{navigationItem.navigationSubCategories &&
navigationItem.navigationSubCategories.map(
(navigationSubcategory, index) => {
return (
<li key={index}>
{activeSubMenu === index
? "active"
: navigationSubcategory.subCategory}
</li>
);
}
)}
</ul>
</li>
);
})}
</ul>
Find the above solution in the following link:
https://codesandbox.io/s/nested-list-accessible-with-keys-9pm3i1?file=/src/App.js:2811-3796

Categories

Resources