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.