Repository: mui/base-ui (2025-07-02T02:49:14Z)

Issue:
[field][form] Error during form submission after dynamically adding or removing `Field.Root`s
# Bug report

## Current behavior

When dynamically adding or removing `<Field.Root>` components and submitting the form, you will encounter the following error, causing the UI to stop functioning:

```
Form.js:32 Uncaught TypeError: Cannot read properties of null (reading 'focus')
    at Form.Form.useEventCallback[focusControl] (Form.js:32:13)
    at trampoline (useEventCallback.js:23:45)
    at onSubmit (Form.js:63:11)
    at executeDispatch (react-dom-client.development.js:16501:9)
    at runWithFiberInDEV (react-dom-client.development.js:844:30)
    at processDispatchQueue (react-dom-client.development.js:16551:19)
    at react-dom-client.development.js:17149:9
    at batchedUpdates$1 (react-dom-client.development.js:3262:40)
    at dispatchEventForPluginEventSystem (react-dom-client.development.js:16705:7)
    at dispatchEvent (react-dom-client.development.js:20815:11)
    at dispatchDiscreteEvent (react-dom-client.development.js:20783:11)
```

## Expected behavior

I expect the dynamically mounted and unmounted values to behave appropriately.

## Reproducible example

https://codesandbox.io/p/devbox/vvzwzp

> [!NOTE]  
> To reproduce the error, quickly double-click the `Switch` to add / remove a form element, then attempt to submit the form. This sequence will trigger the error.

## Base UI version

1.0.0-beta.1

## Which browser are you using?

Arc

## Which OS are you using?

Mac OS

## Additional context

![Image](https://github.com/user-attachments/assets/3e59631c-dd4b-423c-b0f7-7c773383f59b)


Discussion:
Here's a repro without NextJS (it's very slow in CSB for me) https://codesandbox.io/p/sandbox/wizardly-resonance-wpv8fh

The issue is that when `Field`s are unmounted, it's not being de-registered from the parent `Form`. If an unmounted field happens to be the first invalid field in the form, it will try to `.focus()` it which causes the error.
A workaround for now is to set `key` on the `Form` so it internally resets based on the state that changes the number of `Field`s present inside the Form - so the `checked` state in the example:

```jsx
<Form key={String(checked)}
```

Though this breaks animations, and resets all the fields state unless it's made controlled
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@2231
```



Patch:
diff --git a/packages/react/src/field/useField.ts b/packages/react/src/field/useField.ts
index 1e56af282a..5fbb292744 100644
--- a/packages/react/src/field/useField.ts
+++ b/packages/react/src/field/useField.ts
@@ -63,6 +63,15 @@ export function useField(params: useField.Parameters) {
     validityData,
     value,
   ]);
+
+  useModernLayoutEffect(() => {
+    const fields = formRef.current.fields;
+    return () => {
+      if (id) {
+        fields.delete(id);
+      }
+    };
+  }, [formRef, id]);
 }
 
 export namespace useField {


Test patch:
diff --git a/packages/react/src/field/root/FieldRoot.test.tsx b/packages/react/src/field/root/FieldRoot.test.tsx
index accaed9057..fc243c4bd0 100644
--- a/packages/react/src/field/root/FieldRoot.test.tsx
+++ b/packages/react/src/field/root/FieldRoot.test.tsx
@@ -305,6 +305,49 @@ describe('<Field.Root />', () => {
         switch: false,
       });
     });
+
+    it('unmounted fields are excluded from the validate fn', async () => {
+      const validateSpy = spy();
+      function App() {
+        const [checked, setChecked] = React.useState(true);
+
+        return (
+          <Form>
+            <input type="checkbox" checked={checked} onChange={() => setChecked(!checked)} />
+            {checked && (
+              <Field.Root name="input1">
+                <Field.Control data-testid="input1" defaultValue="one" />
+              </Field.Root>
+            )}
+            <Field.Root name="input2" validate={validateSpy}>
+              <Field.Control data-testid="input2" defaultValue="two" />
+            </Field.Root>
+            <button>Submit</button>
+          </Form>
+        );
+      }
+      await render(<App />);
+
+      const input = screen.getByTestId('input2');
+      fireEvent.focus(input);
+      fireEvent.blur(input);
+
+      expect(validateSpy.callCount).to.equal(1);
+      expect(validateSpy.firstCall.args[1]).to.deep.equal({
+        input1: 'one',
+        input2: 'two',
+      });
+
+      fireEvent.click(screen.getByRole('checkbox'));
+
+      fireEvent.focus(input);
+      fireEvent.blur(input);
+
+      expect(validateSpy.callCount).to.equal(2);
+      expect(validateSpy.lastCall.args[1]).to.deep.equal({
+        input2: 'two',
+      });
+    });
   });
 
   describe('prop: validationMode', () => {
diff --git a/packages/react/src/form/Form.test.tsx b/packages/react/src/form/Form.test.tsx
index adc665f58f..b7ef44d029 100644
--- a/packages/react/src/form/Form.test.tsx
+++ b/packages/react/src/form/Form.test.tsx
@@ -35,6 +35,43 @@ describe('<Form />', () => {
     expect(onSubmit.called).to.equal(false);
   });
 
+  it('unmounted fields should be removed from the form', async () => {
+    const submitSpy = spy((event) => event.preventDefault());
+    function App() {
+      const [checked, setChecked] = React.useState(true);
+
+      return (
+        <Form onSubmit={submitSpy}>
+          <Field.Root name="name">
+            <Field.Control defaultValue="Alice" />
+          </Field.Root>
+
+          <input type="checkbox" checked={checked} onChange={() => setChecked(!checked)} />
+
+          {checked && (
+            <Field.Root name="email">
+              <Field.Control defaultValue="" required data-testid="email" />
+            </Field.Root>
+          )}
+
+          <button>Submit</button>
+        </Form>
+      );
+    }
+
+    const { user } = await render(<App />);
+
+    const submit = screen.getByText('Submit');
+
+    await user.click(submit);
+    expect(submitSpy.callCount).to.equal(0);
+    expect(screen.getByTestId('email')).to.have.attribute('aria-invalid', 'true');
+
+    await user.click(screen.getByRole('checkbox'));
+    await user.click(submit);
+    expect(submitSpy.callCount).to.equal(1);
+  });
+
   describe('prop: errors', () => {
     function App() {
       const [errors, setErrors] = React.useState<Form.Props['errors']>({