Provide a general summary of the issue here
useNumberFieldState compares formatOptions using reference equality (!==) at line 158 of useNumberFieldState.ts. When
consumers pass formatOptions as an inline object literal (e.g. formatOptions={{style: 'currency', currency:
'USD'}}), a new object reference is created on every parent re-render. This triggers
setInputValue(format(numberValue)), resetting the user's typed input to the last committed value.
This also affects the useMemo dependencies on lines 136–138, causing unnecessary recreation of NumberParser and
NumberFormatter on every render.
Any component that wraps NumberField and passes inline formatOptions may inherits this bug — including downstream
libraries like HeroUI (heroui-inc/heroui#6414).
🤔 Expected Behavior?
The typed input value should be preserved across parent re-renders as long as the formatOptions content has not
actually changed. Re-renders with a new but shallowly-equal formatOptions object should be a no-op.
😯 Current Behavior
Every parent re-render creates a new formatOptions object reference when passed inline. The comparison at line 158 of
useNumberFieldState.ts:
if (!Object.is(numberValue, prevValue) || locale !== prevLocale || formatOptions !== prevFormatOptions) {
setInputValue(format(numberValue));
}
evaluates formatOptions !== prevFormatOptions as true even though the content is identical, causing
setInputValue(format(numberValue)) to overwrite the user's in-progress input with the formatted committed value.
Additionally, formatOptions is used as a dependency in useMemo on lines 136–138. The unstable reference causes
NumberParser, NumberFormatter, and resolvedOptions to be recreated every render, even when the format has not
changed.
💁 Possible Solution
Since all Intl.NumberFormatOptions values are primitives (string, number, boolean), I thought a shallow equality check is
sufficient. The codebase already has a precedent — useDateFormatter.ts solves the same problem with
Intl.DateTimeFormatOptions using a local isEqual function.
Stabilize the formatOptions reference early in the hook using useRef + shallow equality, so all downstream consumers
(useMemo, the prev-state comparison) benefit automatically:
let formatOptionsRef = useRef(formatOptions);
if (!isEqualFormatOptions(formatOptions, formatOptionsRef.current)) {
formatOptionsRef.current = formatOptions;
}
formatOptions = formatOptionsRef.current;
function isEqualFormatOptions(a: Intl.NumberFormatOptions | undefined, b: Intl.NumberFormatOptions | undefined) {
if (a === b) return true;
if (!a || !b) return false;
let aKeys = Object.keys(a);
let bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
for (let key of aKeys) {
if (b[key] !== a[key]) return false;
}
return true;
}
I have a working implementation with all existing tests passing (480/480). If this approach looks acceptable, I'd be
happy to open a PR.
🔦 Context
I encountered this bug while using HeroUI's NumberField component, which is built on top of React Aria's NumberField.
After tracing through the layers, I identified the root cause here in useNumberFieldState.ts. Fixing it at this
level resolves the issue for all downstream consumers.
Related: heroui-inc/heroui#6414
🖥️ Steps to Reproduce
import { useState } from "react";
import { NumberField, Label, Input } from "react-aria-components";
function App() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>
Re-render parent ({count})
</button>
<NumberField formatOptions={{ style: "currency", currency: "USD" }}>
<Label>Price</Label>
<Input />
</NumberField>
</div>
);
}
- Focus the NumberField and type a value (e.g. 42)
- Click the "Re-render parent" button
- Observe the typed value resets to the previously committed value
Reproduction: https://stackblitz.com/edit/vitejs-vite-rqadeopw?file=src%2FApp.tsx
Version
react-aria-components@1.16.0
What browsers are you seeing the problem on?
Chrome
If other, please specify.
No response
What operating system are you using?
macOS
🧢 Your Company/Team
No response
🕷 Tracking Issue
heroui-inc/heroui#6414
Provide a general summary of the issue here
useNumberFieldState compares formatOptions using reference equality (!==) at line 158 of useNumberFieldState.ts. When
consumers pass formatOptions as an inline object literal (e.g. formatOptions={{style: 'currency', currency:
'USD'}}), a new object reference is created on every parent re-render. This triggers
setInputValue(format(numberValue)), resetting the user's typed input to the last committed value.
This also affects the useMemo dependencies on lines 136–138, causing unnecessary recreation of NumberParser and
NumberFormatter on every render.
Any component that wraps NumberField and passes inline formatOptions may inherits this bug — including downstream
libraries like HeroUI (heroui-inc/heroui#6414).
🤔 Expected Behavior?
The typed input value should be preserved across parent re-renders as long as the formatOptions content has not
actually changed. Re-renders with a new but shallowly-equal formatOptions object should be a no-op.
😯 Current Behavior
Every parent re-render creates a new formatOptions object reference when passed inline. The comparison at line 158 of
useNumberFieldState.ts:
evaluates formatOptions !== prevFormatOptions as true even though the content is identical, causing
setInputValue(format(numberValue)) to overwrite the user's in-progress input with the formatted committed value.
Additionally, formatOptions is used as a dependency in useMemo on lines 136–138. The unstable reference causes
NumberParser, NumberFormatter, and resolvedOptions to be recreated every render, even when the format has not
changed.
💁 Possible Solution
Since all Intl.NumberFormatOptions values are primitives (string, number, boolean), I thought a shallow equality check is
sufficient. The codebase already has a precedent — useDateFormatter.ts solves the same problem with
Intl.DateTimeFormatOptions using a local isEqual function.
Stabilize the formatOptions reference early in the hook using useRef + shallow equality, so all downstream consumers
(useMemo, the prev-state comparison) benefit automatically:
I have a working implementation with all existing tests passing (480/480). If this approach looks acceptable, I'd be
happy to open a PR.
🔦 Context
I encountered this bug while using HeroUI's NumberField component, which is built on top of React Aria's NumberField.
After tracing through the layers, I identified the root cause here in useNumberFieldState.ts. Fixing it at this
level resolves the issue for all downstream consumers.
Related: heroui-inc/heroui#6414
🖥️ Steps to Reproduce
Reproduction: https://stackblitz.com/edit/vitejs-vite-rqadeopw?file=src%2FApp.tsx
Version
react-aria-components@1.16.0
What browsers are you seeing the problem on?
Chrome
If other, please specify.
No response
What operating system are you using?
macOS
🧢 Your Company/Team
No response
🕷 Tracking Issue
heroui-inc/heroui#6414