POS NumberField inconsistently calling onChange

I’ve been running into issues with the NumberField component. Everything works fine when you simply use <NumberField value={value} onChange={setValue} />, but the moment you don’t set the state to exactly what was inputted it breaks.

Take this example:

<NumberField
  label="Quantity"
  inputMode="numeric"
  value={val}
  onChange={num => {
    console.log('onChange', num);
    if (!num) {
      setVal(num);
      return;
    }

    const parsed = Number(num);

    if (parsed > 10) {
      setVal('10');
      return;
    }

    setVal(num);
  }}
  max={10}
  required
/>

After entering e.g. 12 it correctly snaps back to 10, but further inputs do not cause a call to onChange. It seems like using onInput would work here, but I would prefer not doing that as the docs explicitly state not to.

I’ve also noticed that onChange is never called again after the value has been set to undefined

Hope this can be fixed, thanks.

Hi Tim,

Thank you for bringing this to our attention. I am able to reproduce the issue on our end, and have logged it as a bug for us to fix.

Edit: The bug stems from a queueing mechanism we have between the POS and the extension. If you try to set the same value twice, the queueing mechanism does not detect a change and thus short-circuits the process. To work around this, you can briefly set the value of the NumberField to a different value. I understand this is not ideal and produces a flicker, but it does keep the NumberField responsive. Basically, I’d recommend doing:

    if (parsed > 10) {
      setVal('0');
      setVal('10');
      return;
    }

Thank you,
Nathan O

Thank you for the work around Nathan!

I ran into a similar problem.
At first, I simply gave up on adjusting the value in real time during input (I just did it later instead).
But it turned out that wasn’t enough - the bug still shows up.

Here’s a basic example of what I’m trying to do:

const [value, setValue] = useState('');
return <>
  <NumberField label={'Value'} inputMode={'numeric'} value={value} onChange={setValue}/>
  <Text variant={'body'}>Value: {value}</Text>
</>;

My code runs inside a worker that communicates with the main POS process: my code tells it what to render and with which props, and in return I receive events like onChange.
As far as I understand, the main process expects that after every onChange the value prop must change. Until that happens, the next onChange won’t be fired, even though visually the input field updates (because that’s handled by the main process).

So, if you type a digit very quickly into the field and immediately erase it, you may end up with two onChange events, but the value prop doesn’t have time to change back and forth, and it looks like it never changed at all. And that’s where the bug appears.
The problem reproduces quite often, especially on less powerful devices: in an Android emulator it’s hard to reproduce (because it’s running on a powerful PC), but on a real phone (especially a cheap Android) it’s very easy. Just tap a digit and backspace quickly a few times, and at some point the <Text> element stops updating.

I think I found a workaround. It’s a bit of a hack, but it works.

Once the onChange events start getting stuck, they can be “unstuck” if the value prop changes in any way - then everything starts working again.
So I did this: 1 second after typing stops, I temporarily append a zero-width space to the value for 0.3s, then remove it.
Here’s the code:

const timerRefresh = useRef(null);

const [value, setValue] = useState('');

const onInputChange = useCallback(
  (input) => {
    setValue(input);
	
    // Refresh input to avoid UI issues with NumberField
	clearTimeout(timerRefresh.current);
	timerRefresh.current = setTimeout(
	  () => {
	    setValue(input + '\u200B'); // Add zero width space in 1 second after typing stops
		timerRefresh.current = setTimeout(
		  () => setValue(input), // And remove it in 0.3 second
		  300,
		);
	  },
      1000,
    );
  },
  [setValue],
);

return <>
  <NumberField label={'Value'} inputMode={'numeric'} value={value} onChange={onInputChange}/>
  <Text variant={'body'}>Value: {value}</Text>
</>;

This way, if you type and delete quickly, even if it gets stuck again, it recovers pretty fast.

But there’s another funny side effect: if the field gets “stuck” and you keep typing digits without letting the timer fire, then when it finally does fire and things “unstick”, all the accumulated onChange events get triggered at once.
That means the <Text> element updates many times in a row automatically, which looks weird.

To solve this, I added a debounce for updating the displayed result. That requires two state variables:

  • one for the raw value (like before);
  • and another for the debounced value used by the rest of the script and rendered in <Text>.

In the end, I made a custom hook to encapsulate all these timers and hacks:

// 
// useNumberFieldValue.js
//

import { useState, useCallback, useRef } from 'react';

/**
 * @param {function(number): void} onChange
 * @param {string} initialValue
 * @returns {[string, function(string): void]}
 */
const useNumberFieldValue = (onChange, initialValue = '') => {
  const [valueStr, setValueStr] = useState(initialValue);

  /**
   * @type {React.RefObject<number|null>}
   */
  const timerOnChange = useRef(null);

  /**
   * @type {React.RefObject<number|null>}
   */
  const timerRefresh = useRef(null);

  /**
   * @type {function(input: string): void}
   */
  const onInputChange = useCallback(
    (input) => {
      setValueStr(input);

      // Call main onChange with debounce 0.1 second
      clearTimeout(timerOnChange.current);
      timerOnChange.current = setTimeout(
        () => {
          onChange(parseInt(input, 10) || 0);
        },
        100,
      );

      // Refresh input to avoid UI issues with NumberField
      clearTimeout(timerRefresh.current);
      timerRefresh.current = setTimeout(
        () => {
          setValueStr(input + '\u200B'); // Add zero width space in 1 second after typing stops
          timerRefresh.current = setTimeout(
            () => setValueStr(input), // And remove it in 0.3 second
            300,
          );
        },
        1000,
      );
    },
    [onChange, setValueStr],
  );

  return [valueStr, onInputChange];
};

export default useNumberFieldValue;
//
// component.js
//

import React, { useState } from 'react';
import { NumberField, Text } from '@shopify/ui-extensions-react/point-of-sale';
import useNumberFieldValue from './useNumberFieldValue';

const Component = function(){
  const [value, setValue] = useState(0);
  const [valueInput, onInputChange] = useNumberFieldValue(setValue, '');

  return <>
    <NumberField label={'Value'} inputMode={'numeric'} value={valueInput} onChange={onInputChange}/>
    <Text variant={'body'}>Value: {value}</Text>
  </>;
};

export default Component;

With this approach, it works reliably and doesn’t freeze no matter how quickly you type and delete. Sometimes the <Text> updates with a slight delay, but that’s acceptable.

Of course, it would be much better if Shopify fixed this on their end so that onChange events don’t stop firing just because the value prop didn’t change.