The component I'm working on is a time input for a form. The form is relatively complex and is generated dynamically, with different fields appearing based on data nested inside other data. I'm managing the state of the form with useReducer, which has worked very well so far. Now that I'm trying to implement a time input component, I'd like to have some basic validation, in particular so I don't get junk non-formatted data into my database. My way of thinking about it was that my database wants one thing: a time, formatted per ISO8601. The UI on the other hand could get that date any number of ways, in my case via an "hour" field, a "minute" field, and eventually an am/pm field. Since multiple fields are being individually validated, and then combined into a single ISO string, my approach was to have useState manage the individual fields and their validation, and then dispatch a single processed ISO string to my centralized state.
To get that to work I tried having the onChange listener of the input fields simply update the local state with a validated input, and then have useEffect "listen" to the local state using its dependency array. So each time local state changes, the useEffect callback dispatches an action with the new input, now processed into an ISO string, in its payload. I was a bit surprised this worked, but I still have a lot to learn about.. all of it. Anyways this worked great, or so I thought..
Since the component in question, TimePiece, is being rendered dynamically (inside nested loops) inside of its parent's parent component, when the user changes the form a bit, the TimePiece component gets rendered with new props and state. But therein lies the rub, every time TimePiece is rendered, it has the same state as every other "instance" of TimePiece (it's a function component though). I used some console.logs to find out it's actually maintaining it's separate state until the moment in renders, when it's then set to the state of the last "instance" that was modified.
My central useReducer state is keyed by a series of ids, so it's able to persist as the user changes the view without a similar problem. It's only the local state which isn't behaving properly, and somewhere on the re-render it sends that state to the central useReducer state and overwrites the existing, correct value...
Something is definitely off, but I keep trying different version and just breaking the thing. At one point it was actually fluttering endlessly between the two states... I thought I would consult the internet. Am I doing this completely wrong? Is it some slight tweak? Should I not have dispatch inside of useEffect with a local state dependency?
In particular, is it strange to combine useState and useReducer, either broadly or in the specific way I've done it?
Here is the code.. if it makes no sense at all, I could make a mock version, but so often the problem lies in the specifics so I thought I'd see if anyone has any ideas. Thanks a bunch.
The functions validateHours and validateMinutes shouldn't have much effect on the operation if you want to ignore those (or so I think.....).
"Mark" is the name of the field state as it lives in memory, e.g. the ISO string. io is what I'm calling the user input.
function TimePiece({ mark, phormId, facetParentId, pieceType, dispatch, markType, recordId }) {
const [hourField, setHourField] = useState(parseIsoToFields(mark).hour);
const [minuteField, setMinuteField] = useState(parseIsoToFields(mark).minute);
function parseFieldsToIso(hour, minute) {
const isoTime = DateTime.fromObject({ hour: hour ? hour : '0', minute: minute ? minute : '0' });
return isoTime.toISOTime();
}
function parseIsoToFields(isoTime) {
const time = DateTime.fromISO(isoTime);
const hour = makeTwoDigit(`${time.hour}`);
const minute = makeTwoDigit(`${time.minute}`);
return {
hour: hour ? hour : '',
minute: minute ? minute : ''
}
}
function makeTwoDigit(value) {
const twoDigit = value.length === 2 ? value :
value.length === 1 ? '0' + value : '00'
return twoDigit;
}
function validateHours(io) {
const isANumber = /\d/g;
const is01or2 = /[0-2]/g;
if (isANumber.test(io) || io === '') {
if (io.length < 2) {
setHourField(io)
} else if (io.length === 2) {
if (io[0] === '0') {
setHourField(io);
} else if ( io[0] === '1' && is01or2.test(io[1]) ) {
setHourField(io);
} else {
console.log('Invalid number, too large..');
}
}
} else {
console.log('Invalid characeter..');
}
}
function validateMinutes(io) {
const isANumber = /\d/g;
const is0thru5 = /[0-5]/;
if (isANumber.test(io) || io === '') {
if (io.length < 2) {
setMinuteField(io);
} else if (is0thru5.test(io[0])) {
setMinuteField(io);
} else {
console.log('Invalid number, too large..');
}
} else {
console.log('Invalid character..');
}
}
useEffect(() => {
dispatch({
type: `${markType}/io`,
payload: {
phormId,
facetId: facetParentId,
pieceType,
io: parseFieldsToIso(hourField, minuteField),
recordId
}
})
}, [hourField, minuteField, dispatch, phormId, facetParentId, pieceType, markType, recordId])
return (
<React.Fragment>
<input
maxLength='2'
value={hourField} onChange={(e) => {validateHours(e.target.value)}}
style={{ width: '2ch' }}
></input>
<span>:</span>
<input
maxLength='2'
value={minuteField}
onChange={(e) => { validateMinutes(e.target.value) }}
style={{ width: '2ch' }}
></input>
</React.Fragment>
)
}
P.S. I made another version which avoids using useState and instead relies on one functions to validate and process the fields, but for some reason it seemed weird, even if it was more functional. Also having local state seemed ideal for implementing something that highlights incorrect inputs and says "invalid number" or whatever, instead of simply disallowing that input.
EDIT: Live code here: https://codesandbox.io/s/gv-timepiecedemo-gmkmp?file=/src/components/TimePiece.js
TimePiece is a child of Facet, which is a child of Phorm or LogPhorm, which is a child of Recorder or Log... Hopefully it's somewhat legible.
As suggested I managed to get it working on Codesandbox. I was running a local Node server to route to a Mongo database and didn't know how to set that up, so I just plugged it with a dummy database, shouldn't effect the problem at hand.
To create the problem, in the top left dropdown menu, choose "Global Library", and then click on either "Pull-Up" or "Push-Up". Then in the main window, try typing in to the "Time" field. "Pull-Up" and "Push-Up" are both using this TimePiece component, when you click on the other one, you'll see that the Time field there has changed to be the same as other Time field. The other fields ("Reps", "Load") each maintain their own independent state when you switch between exercises, which is what I'm going for.
If you click "generate record" withs some values in the "Time" field, it makes a "record" which will now show up on the right side. If you click on that it expands into a similar display as the main window. The same problem happens over here with the "Time" field, except the state is independent from the state in the main window. So there are basically two states: one for all Time fields in the main window, one for all Time fields in the right window. Those are being rendered by different parents, Phorm and LogPhorm respectively, maybe that is a hint?
Thanks all!!