React: number input allowing blank values

What would be an idiomatic way to solve the following issue? Say I have a number input bound to some state:

@react.component
let make = () => {
  let (increment, setIncrement) = React.useState(() => 1)

  let onInputIncrement = e => {
    ReactEvent.Form.target(e)["value"]
      ->Int.fromString
      ->Option.getWithDefault(increment)
      ->Function.const
      ->setIncrement
  }

  <>
    <Counter increment />

    <input
      type_="number"
      min="1"
      step=1.0
      value={increment->Int.toString}
      onInput=onInputIncrement
    />
  </>
}

This handling of the Option resulting from Int.toString means that I can never backspace to delete the entire contents of the input. I want to be able to blank out the input while typing, with the value staying the same in the meantime. The only solution I’ve found for this (besides switching to onChange) is to introduce a secondary cache state:

@react.component
let make = () => {
  let (incrementValue, setIncrement) = React.useState(() => Some(1))
  let (incrCache, setIncrCache) = React.useState(() => 1)
  let increment = incrementValue->Option.getWithDefault(incrCache)

  let onInputIncrement = e => {
    let value = ReactEvent.Form.target(e)["value"]->Int.fromString
    setIncrement(_ => value)
    value->Option.mapWithDefault((), i => setIncrCache(_ => i))
  }

  <>
    <Counter increment />

    <input
      type_="number"
      min="1"
      step=1.0
      value={incrementValue->Option.mapWithDefault("", Int.toString)}
      onInput=onInputIncrement
    />
  </>
}

This is quite a tortuous pattern for something which could end up coming up a lot. Is there a better way?

Perhaps instead of making the state an int, make it an option<int>?

Notice I did change it to an option in my second snippet. But I don’t want the state’s value to disappear when the input is blanked, hence the need for a cached value. A better modelling would be something like:

type inputState = Synced | Cached

@react.component
let make = () => {
  let ((inputState, increment), setIncrement) = React.useState(() => (Synced, 1))

  let onInputIncrement = e => {
    let value = ReactEvent.Form.target(e)["value"]->Int.fromString
    setIncrement(_ => switch value {
      | Some(i) => (Synced, i)
      | None => (Cached, increment)
    })
  }

  let inputValue = switch inputState {
    | Synced => increment->Int.toString
    | Cached => ""
  }

  <>
    <Counter increment />

    <input
      type_="number"
      min="1"
      step=1.0
      value=inputValue
      onInput=onInputIncrement
    />
  </>
}

This works, but I just wondered if it’s idiomatic, or if there’s maybe some React mechanism for achieving the same thing.

I’m just wondering what’s the use of keeping a cached value when the entry is blanked? If you blanked it to type a new number, then the cached value will just be invalidated momentarily and it’s useless. If you want to bring the cached value back then as a user you would just use the Undo action while in the input and you’d get the value back. Either way, there’s no use for the cached value.

Well, I’m just playing around right now, and I wanted to compare the experience of binding input values with eg. Vue’s v-model.number or Svelte’s bind:value. But actually I think I like this better because you’re forced to explicitly handle the blank case, whereas Vue will sneak in a ”” value for a field that’s supposed to be number.

In the present case, I’m passing increment to Counter which will display it in the button as “+ {increment}”. I didn’t want to end up with a button saying just “+ “. The alternative would be to default the None case to 1, but it’s weird for it to flash that value briefly while editing some entirely different value.

Admittedly in Vue I’d have to handle this with extra state somehow too, and it’s never come up for me before in the real world. So maybe my initial reaction to the complexity of the solution was overboard. ¯\(ツ)