I want to achieve a dynamically built form with controlled components that scale well. The problem I'm having is updating the state is causing the entire form to re-render (or something expensive?) causing epic type lag.
// Generate some example fields
let defaultFields = {};
for(let n=0; n<1000; n++){
defaultFields[n.toString()] = {'id':n.toString(), 'value':'','label':'Field '+n}
}
const [valuesById, setValuesById] = useState(defaultFields);
const updateValueCallback = React.useCallback((e)=>{
e.preventDefault();
e.persist();
setValuesById(prevValuesById => {
let fieldId = e.target.id;
return {...prevValuesById,
[fieldId]:{
'id':fieldId,
'value':e.target.value,
'label':'Field '+fieldId
}};
});
});
return <div>
{ Object.entries(valuesById).map(([id,formField]) => {
return <p key={formField.id}>
<label>{formField.label}
<SingleLineStringInput isRequired={false} value={formField.value} onChangeCallback={updateValueCallback} id={formField.id} name={'name_'+formField.id} />
</label>
</p>
})
}
</div>;
If the props aren't changing for 999 of the fields then why do they re-render? Or what is actually happening here (the fields don't actually flash in the debug tool but the parent does)? I really need help to understand this better and a fix which isn't too drastically different from what I've done as we've built a large amount of logic on top of this basic structure and have only now realised that it's not scaling.
SingleLineInput:
const SingleLineStringInput = React.memo(({name, id, value, onChangeCallback}) => {
console.log("UPDATING "+id);
return <input className={'input ' + inputClasses.join(' ')} name={name} id={id} type="text"
value={(value === null) ? '' : value}
onChange={onChangeCallback} />
});
Ok I will try to help, you are doing fine using memo and useCallback, but you are not passing the array of dependencies to useCallback, as your callback would be same for every render, you can pass an empty array to it, so it will be the same function on every render,
You can do it as this
const updateValueCallback = React.useCallback((e)=>{
e.preventDefault();
e.persist();
setValuesById(prevValuesById => {
let fieldId = e.target.id;
return {...prevValuesById,
[fieldId]:{
'id':fieldId,
'value':e.target.value,
'label':'Field '+fieldId
}};
});
}, []); // dependencies array
Now if you change a field value on that one will be re-rendered
Sandbox to show you how its done
Hope it helps
Related
I have a React web app that is effectively a ton of Questions. These questions need to be validated/laid-out based on their own state values (ie: must be a number in a number field), as well as on the values of each other. A few examples of the more complex 'validation':
Questions A, B, and C might be required to have non-empty values before allowing a 'save' button.
Question B's allowable range of values might be dependent on the value of question A.
Question C might only show if question A is set to 'true'.
You can imagine many other interactions. The app has hundreds of questions - as such, I have their configuration in a JSON object like this:
{ id: 'version', required: true, label: 'Software Version', xs: 3 },
{
id: 'licenseType', label: 'License Type', xs: 2,
select: {
[DICTIONARY.FREEWARE]: DICTIONARY.FREEWARE,
[DICTIONARY.CENTER_LICENSE]: DICTIONARY.CENTER_LICENSE,
[DICTIONARY.ENTERPRISE_LICENSE]: DICTIONARY.ENTERPRISE_LICENSE
}
},
... etc.
I would then turn this object into actual questions using a map in the FormPage component, the parent of all the questions. Given the need to store these interaction in the closest common parent, I store all of the Question values in a formData state variable object and the FormPage looks like so:
function FormPage(props) {
const [formData, setFormData] = useState(BLANK_REQUEST.asSubmitted);
const handleValueChange = (evt, id) => {
setFormData({ ...formData, [id]: evt.target.value})
}
return <div>
{QUESTIONS_CONFIG.map(qConfig => <Question qConfig={qConfig} value={formData[qConfig.id]} handleValueChange={handleValueChange}/>)}
// other stuff too
</div>
}
The Question component is basically just a glorified material UI textField that has it's value set to props.value and it's onChange set to props.handleValueChange. The rest of the qConfig object and Question component is about layout and irrelevant to the question.
The problem with this approach was that every keypress results in the formData object changing... which results in a re-render of the FormPage component... which then results in a complete re-render/rebuild of all my hundreds of Question components. It technically works, but results performance so slow you could watch your characters show up as you type.
To attempt solve this, I modified Question to hold it's own value in it's own state and we no longer pass formData to it... the Question component looking something like this:
function Question(props) {
const { qConfig, valueChangedListener, defaultValue } = props;
const [value, setValue] = useState(props);
useEffect(() => {
if (qConfig.value && typeof defaultValue !== 'undefined') {
setValue(qConfig.value);
}
}, [qConfig.value])
const handleValueChange = (evt, id) => {
setValue(evt.target.value);
valueChangedListener(evt.target.value, id)
}
return <div style={{ maxWidth: '100%' }}>
<TextField
// various other params unrelated...
value={value ? value : ''}
onChange={(evt) => handleValueChange(evt, qConfig.id)}
>
// code to handle 'select' questions.
</TextField>
</div>
}
Notably, now, when it's value changes, it stores it's own value only lets FormPage know it's value was updated so that FormPage can do some multi-question validation.
To finish this off, on the FormPage I added a callback function:
const processValueChange = (value, id) => {
setFormData({ ...formData, [id]: value })
};
and then kept my useEffect that does cross-question validation based on the formData:
useEffect(() => { // validation is actually bigger than this, but this is a good example
let missingArr = requiredFields.filter(requiredID => !formData[requiredID]);
setDisabledReason(missingArr.length ? "Required fields (" + missingArr.join(", ") + ") must be filled out" : '');
}, [formData, requiredFields]);
the return from FormPage had a minor change to this:
return <div>
{questionConfiguration.map(qConfig =>
<Question
qConfig={qConfig}
valueChangedListener={processValueChange}
/>
</ div>
)
}
Now, my problem is -- ALL of the questions still re-render on every keypress...
I thought that perhaps the function I was passing to the Question component was being re-generated so I tried wrapping processValueChange in a useCallback:
const processValueChange = React.useCallback((value, id) => {
setFormData({ ...formData, [id]: value })
}
},[]);
but that didn't help.
My guess is that even though formData (a state object on the FormPage) is not used in the return... its modification is still triggering a full re-render every time.
But I need to store the value of the children so I can do some stuff with those values.
... but if I store the value of the children in the parent state, it re-renders everything and is unacceptbaly slow.
I do not know how to solve this? Help?
How would a functional component store all the values of its children (for validation, layout, etc)... without triggering a re-render on every modification of said data? (I'd only want a re-render if the validation/layout function found something that needed changing)
EDIT:
Minimal sandbox: https://codesandbox.io/s/inspiring-ritchie-b0yki
I have a console.log in the Question component so we can see when they render.
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>
);
}
I wish to generate inputs based on json, so first I set it to initial state, then in child componenet I want to modify it's field, thing is that component doesnt update... It renders once and have no idea how to make it be updated each time when input onChange change it's value. Any idea how to make value of input be updated each time when I type something?
PARENT
function App() {
const [inputValue, setInputValue] = useState(chunkingRow);
const handleChunkingChange = (e, index) => {
let inputContent = inputValue;
const reg = new RegExp(/^[0-9]+$/);
if (e.target.value.match(reg)) {
inputContent[index] = {
...inputContent[index],
content: e.target.value,
};
setInputValue(inputContent);
console.log(inputValue);
} else console.log('not a number')
};
return (
<div>
<Wrapper>
{Chunk(inputValue, handleChunkingChange)}
</Wrapper>
</div>
);
}
CHILD
const Chunk = (inputValue, handleChunkingChange) => {
return(
<div>
{inputValue.map((el, index) => (
<div key={index}>
<p>{el.title}</p>
{console.log(el.content)}
<input
type="text"
onChange={(e, i) => handleChunkingChange(e, index)}
value={el.content}
/>
</div>
))}
</div>
);
}
link to demo
https://codesandbox.io/s/stoic-mirzakhani-46exz?file=/src/App.js
Not completely sure why this happens, but probably because of the way you handle the input change. It seems to me that component doesn't recognize that array changed. How I managed to fix your code is replacing line 9 in App component with following code:
let inputContent = [...inputValue];
By doing that, array's reference is changed and components are updated.
Just update your code as follow:
let inputContent = [ ...inputValue ];
You are mutating the state object.
let inputContent = inputValue;
That's why the state is not re-rendered. Change it to
let inputContent = [...inputValue];
An example of mutating objects. React compares previous state and current state and renders only if they are different.
const source = { a: 1, b: 2 };
const target = source;
console.log(target);
console.log(target === source); = true
target.b = 99;
console.log({target});
console.log({source}); //source == target due to mutation
console.log(source === target); = true
Remember, never mutate.
With each onClick I am rendering a new react component. In each component I am submitting a different text value. Problem I am having is that when i type in a new text and click the button the newState is set but it updates all rendered components. So I was wondering if there was a way for me use previous states in react. Also the way I thought about handling this issue was by pushing each new state in an array, but it didn't work. What happened was the array would simply be updated with the new value. So how can I solve this issue. Examples would greatly be appreciated.
The problem you have is that you are linking all the components to the same state key.
What you actually need to do is have a state with multiple keys to hold the value for each component.
So here's an example using useState.
const ParentComponent = () => {
const [state, setState] = useState({ val1: '', val2: '' })
return (
<>
<Component1 value={val1} onChange={(value) => setState({ ...state, val1: value })} />
<Component2 value={val2} onChange={(value) => setState({ ...state, val2: value })} />
</>
}
}
By the sounds of things you probably have an array, that gets updates, you so could adapt this concept to work for you.
It's tough to give you a great example without seeing your implementation. I can update mine to help you if you provide more information.
You are right, you need to use an array as state and update it but probably you were not doing it right. Try this:
const ParentComponent = () => {
const [itemsArray, setItemsArray] = useState([])
// Pass this method and use it in the child component
changeItem = (index, key, val) => {
const newArray = [ ...itemsArray ];
newArray[index][key] = val;
setItemsArray(newArray);
}
return (
<>
{
itemsArray && 0 < itemsArray.length &&
itemsArray.map((item, key) => <Component changeItem={changeItem}/>)
}
</>
}
}
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!