Repository: mui/base-ui (2025-08-28T09:40:42Z)

Issue:
[number field] onValueChange handler fires twice
# Bug report

## Current behavior

With a `NumberField.Root` with a `defaultValue`, the `onValueChange` prop is triggered twice on each update ONLY when clicking on the increment/decrement buttons (incrementing using the keyboard only triggers it once).

Might be similar to [#1597](https://github.com/mui/base-ui/pull/1597)

## Expected behavior

`onValueChange` should be triggered only once (with the updated value)

## Reproducible example

[StackBlitz](https://stackblitz.com/edit/stackblitz-starters-qvdtjxdh?file=package.json)

## Base UI version

`^1.0.0-beta.2`

## Which browser are you using?

Chrome Version 139.0.7258.128

## Which OS are you using?

Mac OS

## Additional context

it can be an issue when we use `onValueChange` to trigger side-effects (such as tracking event, fetching, etc)



Discussion:
It repros with in controlled mode with `value` instead of `defaultValue` as well
CS team will pick this up for knowledge sharing!
@mj12albert oops I already fixed this without checking the assignments 😅 
This fix will be available in the next npm release of Base UI.

In the meantime, you can try it out on our Canary release channel:

```sh
npm i https://pkg.pr.new/@base-ui-components/react@2591
```



Patch:
diff --git a/packages/react/src/number-field/root/NumberFieldRoot.tsx b/packages/react/src/number-field/root/NumberFieldRoot.tsx
index 73f7a49d24..f18e52bc20 100644
--- a/packages/react/src/number-field/root/NumberFieldRoot.tsx
+++ b/packages/react/src/number-field/root/NumberFieldRoot.tsx
@@ -167,10 +167,7 @@ export const NumberFieldRoot = React.forwardRef(function NumberFieldRoot(
   const setValue = useEventCallback(
     (unvalidatedValue: number | null, event?: React.MouseEvent | Event, dir?: 1 | -1) => {
       const eventWithOptionalKeyState = event as EventWithOptionalKeyState;
-      let nativeEvent: Event | undefined;
-      if (event) {
-        nativeEvent = isReactEvent(event) ? event.nativeEvent : event;
-      }
+      const nativeEvent = event && isReactEvent(event) ? event.nativeEvent : event;
 
       const details = createBaseUIEventDetails('none', nativeEvent);
       const validatedValue = toValidatedNumber(unvalidatedValue, {
@@ -183,14 +180,21 @@ export const NumberFieldRoot = React.forwardRef(function NumberFieldRoot(
         small: eventWithOptionalKeyState?.altKey ?? false,
       });
 
-      onValueChange?.(validatedValue, details);
+      // Determine whether we should notify about a change even if the numeric value is unchanged.
+      // This is needed when the user input is clamped/snapped to the same current value, or when
+      // the source value differs but validation normalizes to the existing value.
+      const shouldFireChange = validatedValue !== value || unvalidatedValue !== value;
 
-      if (details.isCanceled) {
-        return;
-      }
+      if (shouldFireChange) {
+        onValueChange?.(validatedValue, details);
 
-      setValueUnwrapped(validatedValue);
-      setDirty(validatedValue !== validityData.initialValue);
+        if (details.isCanceled) {
+          return;
+        }
+
+        setValueUnwrapped(validatedValue);
+        setDirty(validatedValue !== validityData.initialValue);
+      }
 
       // Keep the visible input in sync immediately when programmatic changes occur
       // (increment/decrement, wheel, etc). During direct typing we don't want
diff --git a/packages/react/src/number-field/root/useNumberFieldButton.ts b/packages/react/src/number-field/root/useNumberFieldButton.ts
index 4ad1d9d26d..d558ca1cdf 100644
--- a/packages/react/src/number-field/root/useNumberFieldButton.ts
+++ b/packages/react/src/number-field/root/useNumberFieldButton.ts
@@ -158,7 +158,8 @@ export function useNumberFieldButton(
           event.defaultPrevented ||
           isDisabled ||
           !isPressedRef.current ||
-          isTouchingButtonRef.current
+          isTouchingButtonRef.current ||
+          pointerTypeRef.current === 'touch'
         ) {
           return;
         }


Test patch:
diff --git a/packages/react/src/number-field/decrement/NumberFieldDecrement.test.tsx b/packages/react/src/number-field/decrement/NumberFieldDecrement.test.tsx
index 2782c28418..4043026411 100644
--- a/packages/react/src/number-field/decrement/NumberFieldDecrement.test.tsx
+++ b/packages/react/src/number-field/decrement/NumberFieldDecrement.test.tsx
@@ -76,6 +76,24 @@ describe('<NumberField.Decrement />', () => {
     expect(input).to.have.value((0.235).toLocaleString(undefined, { minimumFractionDigits: 3 }));
   });
 
+  it('only calls onValueChange once per decrement', async () => {
+    const handleValueChange = spy();
+    const { user } = await render(
+      <NumberField.Root onValueChange={handleValueChange}>
+        <NumberField.Decrement />
+        <NumberField.Input />
+      </NumberField.Root>,
+    );
+
+    const button = screen.getByRole('button');
+
+    await user.click(button);
+    expect(handleValueChange.callCount).to.equal(1);
+
+    await user.click(button);
+    expect(handleValueChange.callCount).to.equal(2);
+  });
+
   describe('press and hold', () => {
     clock.withFakeTimers();
 
diff --git a/packages/react/src/number-field/increment/NumberFieldIncrement.test.tsx b/packages/react/src/number-field/increment/NumberFieldIncrement.test.tsx
index b40ec6e96f..53a0508e39 100644
--- a/packages/react/src/number-field/increment/NumberFieldIncrement.test.tsx
+++ b/packages/react/src/number-field/increment/NumberFieldIncrement.test.tsx
@@ -76,6 +76,24 @@ describe('<NumberField.Increment />', () => {
     expect(input).to.have.value((2.235).toLocaleString(undefined, { minimumFractionDigits: 3 }));
   });
 
+  it('only calls onValueChange once per increment', async () => {
+    const handleValueChange = spy();
+    const { user } = await render(
+      <NumberField.Root onValueChange={handleValueChange}>
+        <NumberField.Increment />
+        <NumberField.Input />
+      </NumberField.Root>,
+    );
+
+    const button = screen.getByRole('button');
+
+    await user.click(button);
+    expect(handleValueChange.callCount).to.equal(1);
+
+    await user.click(button);
+    expect(handleValueChange.callCount).to.equal(2);
+  });
+
   describe('press and hold', () => {
     clock.withFakeTimers();
 
diff --git a/packages/react/src/number-field/input/NumberFieldInput.test.tsx b/packages/react/src/number-field/input/NumberFieldInput.test.tsx
index 6b2a5be179..ab1e0e571e 100644
--- a/packages/react/src/number-field/input/NumberFieldInput.test.tsx
+++ b/packages/react/src/number-field/input/NumberFieldInput.test.tsx
@@ -237,7 +237,7 @@ describe('<NumberField.Input />', () => {
     await user.click(incrementButton);
 
     expect(input).to.have.value('1');
-    expect(onValueChange.callCount).to.equal(2);
+    expect(onValueChange.callCount).to.equal(1);
 
     await user.click(screen.getByText('external'));
 
@@ -274,7 +274,7 @@ describe('<NumberField.Input />', () => {
     await user.click(decrementButton);
 
     expect(input).to.have.value('4');
-    expect(onValueChange.callCount).to.equal(2);
+    expect(onValueChange.callCount).to.equal(1);
 
     await user.click(screen.getByText('external'));
 
@@ -544,6 +544,6 @@ describe('<NumberField.Input />', () => {
     // Without explicit precision formatting, the behavior depends on the step
     // The current implementation preserves full precision until it differs from canonical
     expect(input).to.have.value((1.235).toLocaleString(undefined, { minimumFractionDigits: 3 }));
-    expect(onValueChange.callCount).to.equal(callCountBeforeBlur + 1);
+    expect(onValueChange.callCount).to.equal(callCountBeforeBlur);
   });
 });