# NumberField: Runaway onChange loop when spam-tapping increment/decrement buttons (iOS)
**Category:** Bug
**Severity:** High
**Component:** `NumberField` (heroui-native-pro)
---
## Description
When using `NumberField` in controlled mode (`value` + `onChange`), rapidly tapping the increment (+) or decrement (-) button on iOS causes `onChange` to fire in an infinite loop. The value runs away to `maxValue` or `minValue` and does not stop even after tapping stops.
This is also reproducible in the official **HeroUI Native iOS app**.
## Environment
- heroui-native-pro: 1.0.0-beta.4 (also reproduced on beta.3)
- heroui-native: ^1.0.3
- expo: ~55.0.26
- react-native: 0.83
- Platform: iOS
## Steps to Reproduce
1. Render a `NumberField` in controlled mode with `value`, `onChange`, `step={50}`, `minValue={0}`, `maxValue={10000}`
2. On iOS, rapidly tap (spam-press) the increment (+) or decrement (-) button
3. Observe the value — it keeps incrementing/decrementing on its own and does not stop
## Expected Behavior
Each tap increments/decrements by `step` once. Rapid taps should increment once per tap. The value should stop changing when tapping stops.
## Actual Behavior
After rapid taps, `onChange` continues firing in a loop. The value runs away to `maxValue`/`minValue` without stopping.
## Root Cause (from source inspection)
Two issues in `useLongPressRepeat`:
### 1. Timer leak on rapid press-in
`onPressIn` sets `timerRef.current` to a new `setTimeout` **without clearing the previous one**. During spam-tapping, if two `onPressIn` events fire before `onPressOut` clears them (iOS touch coalescing), the first timeout is leaked — its ref was overwritten, so `clear()` can never cancel it. That orphaned timeout spawns a `setInterval` that runs indefinitely.
```js
// useLongPressRepeat.js — current code
const onPressIn = useCallback(() => {
actionRef.current();
timerRef.current = setTimeout(() => { // ← overwrites without clearing
intervalRef.current = setInterval(() => { // ← also overwrites without clearing
actionRef.current();
}, interval);
}, delay);
}, [delay, interval]);
```
### 2. Stale closure in increment/decrement
The `increment`/`decrement` callbacks in `NumberFieldRoot` close over `numberValue` via `useCallback`. When the orphaned interval fires, `actionRef.current` may hold a closure with a stale `numberValue`, causing each tick to re-compute the same step and emit duplicate `onChange` calls.
## Suggested Fix
In `useLongPressRepeat`, clear existing timers before starting new ones:
```js
const onPressIn = useCallback(() => {
if (isDisabledRef.current) return;
clear(); // ← clear any existing timer/interval first
actionRef.current();
timerRef.current = setTimeout(() => {
intervalRef.current = setInterval(() => {
actionRef.current();
}, interval);
}, delay);
}, [delay, interval, clear]);
```
Additionally, consider using a ref for `numberValue` inside `increment`/`decrement` instead of closing over it, so interval ticks always read the latest value.
## Minimal Repro
```tsx
import { useCallback, useMemo, useState } from "react";
import { View } from "react-native";
import { Text } from "heroui-native";
import { NumberField } from "heroui-native-pro";
export default function NumberFieldRepro() {
const [amount, setAmount] = useState(0);
const [changeCount, setChangeCount] = useState(0);
const formatOptions = useMemo<Intl.NumberFormatOptions>(
() => ({ style: "currency", currency: "USD", minimumFractionDigits: 0, maximumFractionDigits: 0 }),
[],
);
const handleChange = useCallback((newValue: number) => {
if (isNaN(newValue)) return;
console.log(`[NumberField onChange] newValue=${newValue}`);
setChangeCount((c) => c + 1);
setAmount(newValue);
}, []);
return (
<View className="flex-1 justify-center px-5 gap-6">
<Text.Paragraph>onChange fired {changeCount} times — spam-tap +/- to reproduce</Text.Paragraph>
<NumberField value={amount} onChange={handleChange} minValue={0} maxValue={10000} step={50} formatOptions={formatOptions}>
<NumberField.Group>
<NumberField.DecrementButton />
<NumberField.Input placeholder="$0" className="text-center" />
<NumberField.IncrementButton />
</NumberField.Group>
</NumberField>
</View>
);
}
```
## Workaround (consumer-side)
Guard `onChange` with a tolerance check to suppress duplicate state updates:
```ts
const handleChange = useCallback((v: number) => {
if (isNaN(v)) return;
setAmount(prev => Math.abs(prev - v) < 0.01 ? prev : v);
}, []);
```
Please authenticate to join the conversation.
In Review
🐛 Bug Reports
1 day ago

Stefan Kudla
Get notified by email when there are changes.
In Review
🐛 Bug Reports
1 day ago

Stefan Kudla
Get notified by email when there are changes.