From b455fb779d94925fc41de82b60e04483ddfcca29 Mon Sep 17 00:00:00 2001 From: Githena Date: Fri, 11 Sep 2026 02:42:53 +0000 Subject: [PATCH] fix(react-form,preact-form): prevent unnecessary FormGroup child re-renders (#2377) Subscribe to the form's baseStore with a path-based selector instead of the FormGroup's own store value. This ensures useFormGroup only re-renders when a field *inside this specific group* changes, not when any sibling group's value changes. Issue: changing a field value in a FormGroup caused ALL child components in the group to re-render, even when those components didn't read the changed field. --- packages/preact-form/src/useFormGroup.tsx | 9 ++- packages/react-form/src/useFormGroup.tsx | 9 ++- .../react-form/tests/useFormGroup.test.tsx | 66 +++++++++++++++++++ 3 files changed, 78 insertions(+), 6 deletions(-) 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) + }) })