diff --git a/packages/preact-form/src/useFormGroup.tsx b/packages/preact-form/src/useFormGroup.tsx index 3cd22d6a0d..e5e6f438bd 100644 --- a/packages/preact-form/src/useFormGroup.tsx +++ b/packages/preact-form/src/useFormGroup.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'preact/hooks' import { useSelector } from '@tanstack/preact-store' -import { FormGroupApi, functionalUpdate } from '@tanstack/form-core' +import { FormGroupApi, functionalUpdate, getBy } from '@tanstack/form-core' import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' import type { DeepKeys, @@ -201,9 +201,12 @@ export function useFormGroup< setPrevOptions({ form: opts.form, name: opts.name }) } + // Subscribe to the form's baseStore with a path-based selector so this hook + // only re-renders when a field inside *this* group changes, not when any + // sibling group's value changes (fixes issue #2377). const reactiveStateValue = useSelector( - formGroupApi.store, - (state) => state.value, + formGroupApi.form.baseStore, + (state) => getBy(state.values, opts.name), ) const reactiveMetaIsTouched = useSelector( diff --git a/packages/react-form/src/useFormGroup.tsx b/packages/react-form/src/useFormGroup.tsx index d9d02f4989..5ff9a4c156 100644 --- a/packages/react-form/src/useFormGroup.tsx +++ b/packages/react-form/src/useFormGroup.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from 'react' import { useSelector } from '@tanstack/react-store' -import { FormGroupApi, functionalUpdate } from '@tanstack/form-core' +import { FormGroupApi, functionalUpdate, getBy } from '@tanstack/form-core' import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' import type { DeepKeys, @@ -203,9 +203,12 @@ export function useFormGroup< setPrevOptions({ form: opts.form, name: opts.name }) } + // Subscribe to the form's baseStore with a path-based selector so this hook + // only re-renders when a field inside *this* group changes, not when any + // sibling group's value changes (fixes issue #2377). const reactiveStateValue = useSelector( - formGroupApi.store, - (state) => state.value, + formGroupApi.form.baseStore, + (state) => getBy(state.values, opts.name), ) const reactiveMetaIsTouched = useSelector( diff --git a/packages/react-form/tests/useFormGroup.test.tsx b/packages/react-form/tests/useFormGroup.test.tsx index d8eabf34f3..4dbb9cb9b5 100644 --- a/packages/react-form/tests/useFormGroup.test.tsx +++ b/packages/react-form/tests/useFormGroup.test.tsx @@ -320,4 +320,70 @@ describe('form.FormGroup', () => { expect(button.disabled).toBe(false) expect(onGroupSubmit).toHaveBeenCalledTimes(1) }) + + // Regression test for https://github.com/TanStack/form/issues/2377 + it('should not re-render sibling field components when a field value changes', async () => { + const step1NameRenderCount = { current: 0 } + + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: '' }, + step2: { name: '' }, + }, + }) + + return ( + + {() => ( + <> + {/* This field is inside the same FormGroup as step1 */} + + {/* This field is in a different group (step2) */} + ( + field.handleChange(e.target.value)} + /> + )} + /> + + )} + + ) + } + + function FieldTracker({ + renderCount, + value, + }: { + renderCount: { current: number } + value: string + }) { + renderCount.current++ + return {value} + } + + const { getByTestId } = render() + const initialStep1Renders = step1NameRenderCount.current + + // Type in step2 field — this should NOT cause step1 field to re-render + await user.clear(getByTestId('step2-name')) + await user.type(getByTestId('step2-name'), 'hello') + + await waitFor(() => { + expect(getByTestId('step1-name').textContent).toBe('') + }) + + // step1 name field should not have re-rendered when step2 (different group) changed + const step1RendersAfterStep2Change = + step1NameRenderCount.current - initialStep1Renders + expect(step1RendersAfterStep2Change).toBe(0) + }) })