Background

I want an interesting number input functionality:

I have multiple goals:

  1. The $ and % mode have different validation rules on the number value (e.g. maximum 999999.99 vs 100) and should be re-validated when either input or input type updated.
  2. For same invalid scenarios, $ and % have different error messages (e.g. Please enter an amount vs Please enter a percentage) and the error message should update accordingly when switching between $ and %.
  3. The Save changes button should be enabled/disabled based on whether the value is valid.
  4. The value updates should trigger state update in the parent.
  5. The $ mode rounds number to 2 fixed decimal places, and % mode rounds to integer, when switching between $ and % the rounding should be updated.

Challenge 1: stale error message

If I implement the input of two modes in one go:

1
2
3
4
5
<NumberInput
errorMessages={inputType === InputType.Amount? AMOUNT_ERROR_MESSAGES : PERCENTAGE_ERROR_MESSAGES}
iconPosition={inputType === InputType.Amount? 'left' : 'right'}
{...other props}
>

The error message will be stale like this when I switch between:

This is interesting, from the position of the icon the NumberInput has been notified to re-render, but it did not re-render the error message 🤔

The quick solution

If I change to a switch case statement, the whole thing will be forced to re-render, and the problem is solved:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
switch (inputType) {
case TippingOptionType.Amount: {
return (<NumberInput
errorMessages={ AMOUNT_ERROR_MESSAGES}
iconPosition={'left'}
{...other props}
>)
}
case TippingOptionType.Amount: {
return (<NumberInput
errorMessages={PERCENTAGE_ERROR_MESSAGES}
iconPosition={'right'}
{...other props}
>)
}
default: {
const _exhaustiveCheck: never = inputType
return _exhaustiveCheck
}
}

But why? Why did I get a “Partial” re-render?

Looking into the implementation of NumberInput, the errorMessage state’s setter is invoked when:

  • The value props passed to NumberInput has changed
  • The user has changed the input box’s value or blurred the input
  • If showInitialError is true, when the element first renders

None of the three were triggered, this tells us:

A props update does not cause React to re-render the element.
It just triggers the useEffect hook that subscribes to this props change,
and causes React to update the DOM that is reflected in the returned element.

Because:

  • there is no effect that is triggered by errorMessages and updates errorMessage state
  • <ErrorMessage> part of the returned DOM is not changed because errorMessage state did not change

React does not think it needs to re-render the error message part, so it stays stale.

No effect that is triggered by errorMessages and updates errorMessage state??

Now the part that seems off is that errorMessages props update does not trigger errorMessage state update

The dependency chain looks like this:

  • useCallback validateInput depends on errorMessages
  • useCallback setValue depends on the memorized callback validateInput
  • There is a useEffect that calls validateInput if showInitialError is true and errorMessages update, but when the value is undefined we want showInitialError to be false because we have an empty form.

Challenge 2: the need for boiler plate code to convert between % <-> $

This is the major reason I am very keen to replace the library component with a custom one. Any time user switches between %/$, there has to be a conversion like this * 3 on errors:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const convertPercentageErrorToAmountError = (
error: boolean | undefined,
value: number | string | undefined
) => {
try {
// if the number value now falls in between amount valid range, clear the error
if (
vn(value).toNumber() <= AMOUNT_MAX &&
vn(value).toNumber() > PERCENTAGE_MAX
) {
return false
}
return error
} catch {
return true
}
}

And a conversion like this * 3 on values:

1
2
3
4
5
6
7
8
// if the value is a number, round to 0 decimals for percentage
const convertAmountToPercentage = (value: number | string | undefined) => {
try {
return value && vn(value).toFixed(0)
} catch {
return value
}
}

My initial plan:

If when the tipping option changes, the number input gets re-rendered, then with showInitialError={true} (it will be true because the value will not be undefined), the values will be automatically validated, so the error will be automatically updated.

However after digging into this plan, it looks that a custom component cannot really simplify the code to achieve our UI/UX need. Here’s why:

As discussed in challenge 1, I need a useEffect that is explicitly triggered by the change. This will lead to an infinite loop because when the values are re-rounded and re-validated, the values are changed, then triggers the use effect again.