Skip to content

[NumberField] formatOptions compared by reference causes input to reset on parent re-render #9899

@Wonchang0314

Description

@Wonchang0314

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>
    );
  }
  1. Focus the NumberField and type a value (e.g. 42)
  2. Click the "Re-render parent" button
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions