2

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!!

bsluther
  • 45
  • 4
  • From what you describe it seems that `TimePiece` isn't responding to updated props it receives. Can you include also how `TimePiece` is being rendered by any parent components, reproduction steps, and if possible could you also try creating a ***running*** codesandbox that reproduces this issue that we can live debug in? – Drew Reese Feb 17 '21 at 06:00
  • Ok got it working on codesandbox! Here's the link https://codesandbox.io/s/gv-timepiecedemo-gmkmp?file=/src/components/TimePiece.js. I edited my post with a more detailed description, it does look like something relating to the parents is afoot, although I'm not sure what. Thanks! – bsluther Feb 17 '21 at 19:37
  • The codesandbox doesn't run. I got it running by commenting out the `App` import in `index.js`, but then I'm stuck at the first reproduction step, the dropdown menu has only option, "Loading...". The `TimePiece.js` file is empty, but I found its implementation in `Phorm.js`. – Drew Reese Feb 17 '21 at 20:12
  • Weird, it must not have saved some of the updates I made, I broke the TimePIece off into its own file for clarity before posting it, and had removed the App import as well. I'm not sure what to do since it works on my side.. I hit "Save All" in case it wasn't updated for some reason. – bsluther Feb 17 '21 at 20:33
  • Ok it looks like the fakeDatabase files weren't saved properly. I was able to load it on a different computer now, let me know if that works for you. – bsluther Feb 17 '21 at 21:18

1 Answers1

1

Ok, after spending a few hours just trying to trace the data flow from TimePiece back through all the abstraction to "state", and back, and all I can really say is that you've a ton of prop drilling. Almost all your components consume the same, or very similar, props

What I finally found is that TimePiece doesn't unmount when switching between what I guess you are calling Phorms(??), which you've abstracted via a Widget. Once I found what wasn't unmounting/remounting as I'd expect to display the different hours & minutes state the solution was simple: Add a React key corresponding to the Phorm when you switch between pull-ups and push-ups.

Phorm.js

<Widget
  key={phormId} // <-- add react key here
  mark={marks[facetParentId][piece.pieceType]}
  phormId={phormId}
  facetParentId={facetParentId}
  dispatch={dispatch}
  pieceType={piece.pieceType}
  markType={markType}
  recordId={recordId}
/>

Using a react key here forces React to treat the two exercises widget time pieces as two separate "instances", when you switch between the two the component remounts and recomputes the initial component state in TimePiece.

Edit react-managing-form-state-with-a-combination-of-usereducer-and-usestate

Drew Reese
  • 165,259
  • 14
  • 153
  • 181
  • Wow, thank you! Yeah, that fixed it. Probably should have noticed that.. I appreciate the effort Drew. It is definitely a work in progress and needs lots of improvement and organization.. As for the prop drilling, I was thinking of trying to group some of them for clarity, all the IDs in particular.. the reason I need them though is that they change with every different configuration. Basically you have an exercise (Phorm) which can be described by properties (Facets) which each have some data they collect (Piece), sometimes via a specialized widget (TimePiece). – bsluther Feb 19 '21 at 15:59
  • The weird names are because I got tired of having my data structure confused with existing terms... But anyways, most of the props are the various identifiers that structure the data. Maybe useContext could help somehow? But each prop will be different at each level of abstraction... – bsluther Feb 19 '21 at 16:07
  • 1
    @bsluther Ah yes, I commented about the prop drilling but neglected to mention/suggest more optimal solutions. The React Context API is generally the next-level solution from "Lifting state Up" where once you've raised it several levels it becomes a potential issue that intermediate components that may not necessarily care about these props need to still pass them through. – Drew Reese Feb 19 '21 at 16:31