Two independent state piece causing infinite loop - useEffect - reactjs

I can't wrap my head around the problem I'm experiencing Basically I submit the form and it checks whether or not there are empty values. I then paint the input border either red or green. However, I need to repaint the border all the time, meaning that if user enters a value, the border should turn green (hence the useEffect). I have 2 pieces of state here. One keeps track of validation error indexes (for value === '') The other piece is the createForm state (form fields) itself.
I then send down the indexes via props.
NOTE: infinite loop occurs not on initial render, but when form is submitted with empty values. The infinite loop DOES NOT occur if there is no empty field on form submit.
I'm willing to share additional info on demand.
const [createForm, setCreateForm] = React.useState(() => createFormFields);
const [validationErrorIndexes, setValidationErrorIndexes] = React.useState([]);
//Function that is being triggered in useEffect - to recalculate validaiton error indexes and resets the indexes.
const validateFormFields = () => {
const newIndexes = [];
createForm.forEach((field, i) => {
if (!field.value) {
newIndexes.push(i);
}
})
setValidationErrorIndexes(newIndexes);
}
//(infinite loop occurs here).
React.useEffect(() => {
if (validationErrorIndexes.length) {
validateFormFields();
return;
}
}, [Object.values(createForm)]);
//Function form submit.
const handleCreateSubmit = (e) => {
e.preventDefault();
if (createForm.every(formField => Boolean(formField.value))) {
console.log(createForm)
// TODO: dispatch -> POST/createUser...
} else {
validateFormFields();
}
}
//I then pass down validationErrorIndexes via props and add error and success classes conditionally to paint the border.
{createForm && createForm.length && createForm.map((formEl, i) => {
if (formEl.type === 'select') {
return (
<Select
className={`create-select ${(validationErrorIndexes.length && validationErrorIndexes.includes(i)) && 'error'}`}
styles={customStyles}
placeholder={formEl.label}
key={i}
value={formEl.value}
onChange={(selectedOption) => handleOptionChange(selectedOption, i)}
options={formEl.options}
/>
)
}
return (
<CustomInput key={i} {...{ label: formEl.label, type: formEl.type, value: formEl.value, formState: createForm, formStateSetter: setCreateForm, i, validationErrorIndexes }} />
)
})}

Ok, so here is what is happening:
Initial render - validationErrorIndexes is empty, bugged useEffect does not hit if and passes.
You click submit with 1 empty field - submit calls validateFormFields, it calculates, setValidationErrorIndexes is set, now its length is non zero and if in bugged useEffect will be hit. And here we got a problem...
The problem: on each rerender of your component Object.values(createForm) which are in your dependency array are evaluated and returning new [array]. Every single rerender, not the old one with same data, the new one with same data. And asuming the if guard is gone now due to length is non 0 - validateFormFields is called again. Which does its evaluations, and setting new data with setValidationErrorIndexes. Which causes rerendering. And Object.values(createForm) returns a new array again. So welp.
So basically, 2 solutions.
One is obvious and just replace [Object.values(createForm)] with just [createForm]. (why do you need Object.values here btw?)
Second one - well if it a must to have [Object.values(createForm)].
const validateFormFields = () => {
const newIndexes = [];
console.log(createForm);
createForm.forEach((field, i) => {
if (!field.value) {
newIndexes.push(i);
}
});
console.log(newIndexes);
setValidationErrorIndexes((oldIndexes) => {
// sorry, just an idea. Compare old and new one and return old if equals.
if (JSON.stringify(oldIndexes) === JSON.stringify(newIndexes)) {
return oldIndexes; // will not cause rerender.
}
return newIndexes;
});
};

Related

Make a momentary highlight inside a virtualized list when render is not triggered

I have an extensive list of items in an application, so it is rendered using a virtual list provided by react-virtuoso. The content of the list itself changes based on API calls made by a separate component. What I am trying to achieve is whenever a new item is added to the list, the list automatically scrolls to that item and then highlights it for a second.
What I managed to come up with is to have the other component place the id of the newly created item inside a context that the virtual list has access to. So the virtual list looks something like this:
function MyList(props) {
const { collection } = props;
const { getLastId } useApiResultsContext();
cosnt highlightIndex = useRef();
const listRef = useRef(null);
const turnHighlightOff = useCallback(() => {
highlighIndex.current = undefined;
}, []);
useEffect(() => {
const id = getLastId();
// calling this function also resets the lastId inside the context,
// so next time it is called it will return undefined
// unless another item was entered
if (!id) return;
const index = collection.findIndex((item) => item.id === if);
if (index < 0) return;
listRef.current?.scrollToIndex({ index, align: 'start' });
highlightIndex.current = index;
}, [collection, getLastId]);
return (
<Virtuoso
ref={listRef}
data={collection}
itemContent={(index, item) => (
<ItemRow
content={item}
toHighlight={highlighIndex.current}
checkHighlight={turnHighlightOff}
/>
)}
/>
);
}
I'm using useRef instead of useState here because using a state breaks the whole thing - I guess because Virtuouso doesn't actually re-renders when it scrolls. With useRef everything actually works well. Inside ItemRow the highlight is managed like this:
function ItemRow(props) {
const { content, toHighlight, checkHighligh } = props;
const highlightMe = toHighlight;
useEffect(() => {
toHighlight && checkHighlight && checkHighligh();
});
return (
<div className={highlightMe ? 'highligh' : undefined}>
// ... The rest of the render
</div>
);
}
In CSS I defined for the highligh class a 1sec animation with a change in background-color.
Everything so far works exactly as I want it to, except for one issue that I couldn't figure out how to solve: if the list scrolls to a row that was out of frame, the highlight works well because that row gets rendered. However, if the row is already in-frame, react-virtuoso does not need to render it, and so, because I'm using a ref instead of a state, the highlight never gets called into action. As I mentioned above, using useState broke the entire thing so I ended up using useRef, but I don't know how to force a re-render of the needed row when already in view.
I kinda solved this issue. My solution is not the best, and in some rare cases doesn't highlight the row as I want, but it's the best I could come up with unless someone here has a better idea.
The core of the solution is in changing the idea behind the getLastId that is exposed by the context. Before it used to reset the id back to undefined as soon as it is drawn by the component in useEffect. Now, instead, the context exposes two functions - one function to get the id and another to reset it. Basically, it throws the responsibility of resetting it to the component. Behind the scenes, getLastId and resetLastId manipulate a ref object, not a state in order to prevent unnecessary renders. So, now, MyList component looks like this:
function MyList(props) {
const { collection } = props;
const { getLastId, resetLastId } useApiResultsContext();
cosnt highlightIndex = useRef();
const listRef = useRef(null);
const turnHighlightOff = useCallback(() => {
highlighIndex.current = undefined;
}, []);
useEffect(() => {
const id = getLastId();
resetLastId();
if (!id) return;
const index = collection.findIndex((item) => item.id === if);
if (index < 0) return;
listRef.current?.scrollToIndex({ index, align: 'start' });
highlightIndex.current = index;
}, [collection, getLastId]);
return (
<Virtuoso
ref={listRef}
data={collection}
itemContent={(index, item) => (
<ItemRow
content={item}
toHighlight={highlighIndex.current === index || getLastId() === item.id}
checkHighlight={turnHighlightOff}
/>
)}
/>
);
}
Now, setting the highlightIndex inside useEffect takes care of items outside the viewport, and feeding the getLastId call into the properties of each ItemRow takes care of those already in view.

React - reset state to empty array if more than 2 items selected

I'd like to reset my selectedDogs state to 0 if more than 2 dogs are selected. How can I do this? At the moment, the array just keeps growing past 2. If 2 dogs are selected, I'd like to trigger an action (e.g. write to console or something).
const [selectedDogs, setSelectedDogs] = useState<IDog[]>([]);
const updateSelectedDogs = useCallback(
async (dog: IDog) => {
if (selectedDog.length > 2) {
setSelectedDogs([]);
}
const updatedSelectedDogs = [...selectedDogs];
updatedSelectedDogs.push(dog);
setSelectedDogs(updatedSelectedDogs);
},
[selectedDogs],
);
...
<button onClick={() => {updateSelectedDogs(dog).then();}}>Click ({selectedDogs.length})</div>
A solution (if the further code is not needed in the case >2 length), is to return after the setState
const updateSelectedDogs = useCallback(
async (dog: IDog) => {
if (selectedDog.length > 2) {
return setSelectedDogs([]);
}
const updatedSelectedDogs = [...selectedDogs];
updatedSelectedDogs.push(dog);
setSelectedDogs(updatedSelectedDogs);
},
[selectedDogs],
);
This issue likely lies in setting the state of selectedDogs to an empty array and then accessing the array in the next line, by which point the setState (which functions asynchronously) might not have been completed, thus returning an old state in the spread on the following line.

Why doesn't useRef trigger a child rerender in this situation?

I'm running into an issue where my hobbies list is not being displayed if it was previously undefined.
The goal is to cycle through a list of hobbies and display them in the UI. If one hobby is passed, only one should be displayed. If none are passed, nothing should be displayed.
Basically, setHobbies(["Cycling"]) followed by setHobbies(undefined) correctly turns off the hobbies render, but then the next setHobbies(["Reading"]) doesn't show up.
Using a debugger I've been able to verify that the relevant code in the useEffect hook in EmployeeInfoWrapper does get triggered, and hobbiesRef.current set accordingly, but it doesn't cause EmployeeInfo to rerender.
Container:
const Container = () => {
const [hobbies, setHobbies] = React.useState<string[]>();
setLabels(["Board games"]); // example of how it's set; in reality this hook is passed down and set in lower components
return (
<SpinnerWrapper hobbies=hobbies otherInfo=otherInfo />
);
};
EmployeeInfoWrapper:
export const EmployeeInfoWrapper = (props) => {
const { hobbies, otherInfo } = props;
const [indx, setIndx] = React.useState<number>(0);
// this doesn't work due to https://github.com/facebook/react/issues/14490, shouldn't matter though because it's handled in the useEffect
const hobbyRef = React.useRef(hobbies?.length ? hobbies[indx] : "");
useEffect(() => {
if (hobbies?.length > 1) { // doubt this is relevant as my bug is with 0 or 1-length hobbies prop
hobbyRef.current = hobbies[indx];
setTimeout(() => setIndx((indx + 1) % hobbies.length), 2000);
}
if (hobbies?.length == 1) {
hobbyRef.current = hobbies[indx]; // can see with debugger this line is hit
}
if (!hobbies?.length) { // covers undefined or empty cases
hobbyRef.current = "";
}
}, [hobbies]);
return (
<EmployeeInfo hobby={hobbyRef.current} otherInfo={otherInfo} />
);
};

React - UseEffect not re-rendering with new data?

This is my React Hook:
function Student(props){
const [open, setOpen] = useState(false);
const [tags, setTags] = useState([]);
useEffect(()=>{
let input = document.getElementById(tagBar);
input.addEventListener("keyup", function(event) {
if (event.keyCode === 13) {
event.preventDefault();
document.getElementById(tagButton).click();
}
});
},[tags])
const handleClick = () => {
setOpen(!open);
};
function addTag(){
let input = document.getElementById(tagBar);
let tagList = tags;
tagList.push(input.value);
console.log("tag");
console.log(tags);
console.log("taglist");
console.log(tagList);
setTags(tagList);
}
const tagDisplay = tags.map(t => {
return <p>{t}</p>;
})
return(
<div className="tags">
<div>
{tagDisplay}
</div>
<input type='text' id={tagBar} className="tagBar" placeholder="Add a Tag"/>
<button type="submit" id={tagButton} className="hiddenButton" onClick={addTag}></button>
<div>
);
What I am looking to do is be able to add a tag to these student elements (i have multiple but each are independent of each other) and for the added tag to show up in the tag section of my display. I also need this action to be triggerable by hitting enter on the input field.
For reasons I am not sure of, I have to put the enter binding inside useEffect (probably because the input element has not yet been rendered).
Right now when I hit enter with text in the input field, it properly updates the tags/tagList variable, seen through the console.logs however, even though I set tags to be the re-rendering condition in useEffect (and the fact that it is also 1 of my states), my page is not updating with the added tags
You are correct, the element doesn't exist on first render, which is why useEffect can be handy. As to why its not re-rendering, you are passing in tags as a dependency to check for re-render. The problem is, tags is an array, which means it compares the memory reference not the contents.
var myRay = [];
var anotherRay = myRay;
var isSame = myRay === anotherRay; // TRUE
myRay.push('new value');
var isStillSame = myRay === anotherRay; // TRUE
// setTags(sameTagListWithNewElementPushed)
// React says, no change detected, same memory reference, skip
Since your add tag method is pushing new elements into the same array reference, useEffect thinks its the same array and is not re-triggers. On top of that, React will only re-render when its props change, state changes, or a forced re-render is requested. In your case, you aren't changing state. Try this:
function addTag(){
let input = document.getElementById(tagBar);
let tagList = tags;
// Create a new array reference with the same contents
// plus the new input value added at the end
setTags([...tagList, input.value]);
}
If you don't want to use useEffect I believe you can also use useRef to get access to a node when its created. Or you can put the callback directly on the node itself with onKeyDown or onKeyPress
I can find few mistake in your code. First, you attaching event listeners by yourself which is not preferred in react. From the other side if you really need to add listener to DOM inside useEffect you should also clean after you, without that, another's listeners will be added when component re-rendered.
useEffect( () => {
const handleOnKeyDown = ( e ) => { /* code */ }
const element = document.getElementById("example")
element.addEventListener( "keydown", handleOnKeyDown )
return () => element.removeEventListener( "keydown", handleOnKeyDown ) // cleaning after effect
}, [tags])
Better way of handling events with React is by use Synthetic events and components props.
const handleOnKeyDown = event => {
/* code */
}
return (
<input onKeyDown={ handleOnKeyDown } />
)
Second thing is that each React component should have unique key. Without it, React may have trouble rendering the child list correctly and rendering all of them, which can have a bad performance impact with large lists or list items with many children. Be default this key isn't set when you use map so you should take care about this by yourself.
tags.map( (tag, index) => {
return <p key={index}>{tag}</p>;
})
Third, when you trying to add tag you again querying DOM without using react syntax. Also you updating your current state basing on previous version which can causing problems because setState is asynchronous function and sometimes can not update state immediately.
const addTag = newTag => {
setState( prevState => [ ...prevState, ...newTage ] ) // when you want to update state with previous version you should pass callback which always get correct version of state as parameter
}
I hope this review can help you with understanding React.
function Student(props) {
const [tags, setTags] = useState([]);
const [inputValue, setInputValue] = useState("");
const handleOnKeyDown = (e) => {
if (e.keyCode === 13) {
e.preventDefault();
addTag();
}
};
function addTag() {
setTags((prev) => [...prev, inputValue]);
setInputValue("");
}
return (
<div className="tags">
<div>
{tags.map((tag, index) => (
<p key={index}>{tag}</p>
))}
</div>
<input
type="text"
onKeyDown={handleOnKeyDown}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Add a Tag"
/>
<button type="submit" onClick={addTag}>
ADD
</button>
</div>
);
}

react input cursor moves to the end after update

When I update the value in my input field, the cursor moves to the end of the field, but I want it to stay where it is. What could be causing this issue?
<Input
type="text"
placeholder="test
name="test"
onChange={getOnChange(index)}
value={testVal}/>
where Input is a component for the text input field, and getOnChange is:
const getOnChange = (index) =>
(event) => props.onChangeTest(event, index);
This is then carried over to the parent component, where I dispatch to update the state via Redux. I can see that the state is being updated fine, but the problem is the cursor is not staying in position, and is always moving to the end of the text
If the cursor jumps to the end of the field it usually means that your component is remounting. It can happen because of key property change on each update of the value somewhere in your parent or changes in your components tree. It's hard to tell without seeing more code. Prevent remounting and the cursor should stop jumping.
Use this effect to track mounting/unmounting
useEffect(() => {
console.log('mounted');
return () => {
console.log('unmounted')
}
}, []);
I would suggest using hooks to solve this
const Component = ({ onChange }) => {
const [text, setText] = useState("");
const isInitialRun = useRef(false);
useEffect(() => {
if (isInitialRun.current) {
onChange(text);
} else {
isInitialRun.current = true;
}
}, [text]);
// or if you want to have a delay
useEffect(() => {
if (isInitialRun.current) {
const timeoutId = setTimeout(() => onChange(text), 500);
return () => clearTimeout(timeoutId);
} else {
isInitialRun.current = true;
}
}, [text])
return (
<Input
type="text"
placeholder="test
name="test"
onChange={setText}
value={text}/>
);
}
To prevent initial call, when nothing changed isInitialRun used
This is the downside of the controlled component design pattern. I've been facing this problem for a long time and just lived with it. But there's an idea that I wanted to try in my spare time but end up never trying it (yet). Perhaps continuing with my idea could help you come up with the solution you need?
<Input
type="text"
placeholder="test
name="test"
onChange={getOnChange(index)}
value={testVal}
/>
// From props.onChangeTest
const onChangeTest = (event, index) => {
// TODO: Memorize the position of the cursor
this.setState({ testVal: event.target.value })
// Because setState is asynchronous
setTimeout(() => {
// TODO:
// Programmatically move cursor back to the saved position
// BUT it must increase/decrease based on number of characters added/removed
// At the same time considering if the characters were removed before or after the position
// Theoretically do-able, but it's very mind-blowing
// to come up with a solution that can actually 'nail it'
}, 0)
}
★ If this is taking too much time and you just want to get work done and ship your app, you might wanna consider using the uncontrolled component design pattern instead.
I was facing same issue, it was due to 2 sequential setState statements. changing to single setState resolved the issue. Might be helpful for someone.
Code before fix:
const onChange = (val) => {
// Some processing here
this.setState({firstName: val}, () => {
this.updateParentNode(val)
})
}
const updateParentNode = (val) => {
this.setState({selectedPerson: {firstName: val}})
}
Code After Fix
const onChange = (val) => {
// Some processing here
this.updateParentNode(val)
}
const updateParentNode = (val) => {
this.setState({selectedPerson: {firstName: val}, firstName: val})
}
You have two options.
make it an uncontrolled input (you can not change the input value later)
make it a properly controlled input
There is code missing here, so I can't say what the problem is.
setState is not the issue: https://reactjs.org/docs/forms.html#controlled-components
If you use setState in the callback React should preserve the cursor position.
Can you give a more complete example?
Is testVal a property that is manipulated from outside the component?
This totally worked for me (the other solutions did not):
const handleChange = (e, path, data) => {
let value = _.isObject(data) ? data.value : data;
let clonedState = { ...originalState };
// save position of cursor
const savedPos = e.target.selectionStart;
_.set(clonedState, path, value); // setter from lodash/underscore
// this wil move cursor to the end
setState({ ...clonedState }); // some use state setter
setTimeout(() => {
// restore cursor position
e.target.setSelectionRange(savedPos, savedPos);
}, 0)
};
Have this on my template (using semantic-ui):
<Input
type="text"
readOnly={false}
onChange={(e, data) => {
handleChange(e, "field", data);
}}
value={state.field}>
</Input>
For me, I was having a <ComponentBasedOnType> and that was the issue, I changed my logic and render my components with a condition && in the parent component.
The cursor on an input will be pushed to the end when you dynamically update the input value through code, which it seems like you are doing because I can see value={testVal} :)
This is a common issue on fields that use input masking!

Resources