Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/fix-array-field-rerender-regression.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@tanstack/react-form': patch
---

fix(react-form): prevent array field re-render when child property changes

Array fields with `mode="array"` were incorrectly re-rendering when a property on any array element was mutated. This was a regression introduced in v1.27.0 by the React Compiler compatibility changes.

The fix ensures that `mode="array"` fields only re-render when the array length changes (items added/removed), not when individual item properties are modified.

Fixes #1925
29 changes: 15 additions & 14 deletions packages/react-form/src/useField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,16 @@ export function useField<
setPrevOptions({ form: opts.form, name: opts.name })
}

const reactiveStateValue = useStore(fieldApi.store, (state) => state.value)
// For array mode, only track length changes to avoid re-renders when child properties change
// See: https://github.com/TanStack/form/issues/1925
const reactiveStateValue = useStore(
fieldApi.store,
(opts.mode === 'array'
? (state) => Object.keys((state.value as unknown) ?? []).length
: (state) => state.value) as (
state: typeof fieldApi.state,
) => TData | number,
)
const reactiveMetaIsTouched = useStore(
fieldApi.store,
(state) => state.meta.isTouched,
Expand Down Expand Up @@ -253,7 +262,10 @@ export function useField<
...fieldApi,
get state() {
return {
value: reactiveStateValue,
// For array mode, reactiveStateValue is the length (for reactivity tracking),
// so we need to get the actual value from fieldApi
value:
opts.mode === 'array' ? fieldApi.state.value : reactiveStateValue,
get meta() {
return {
...fieldApi.state.meta,
Expand Down Expand Up @@ -314,6 +326,7 @@ export function useField<
return extendedApi
}, [
fieldApi,
opts.mode,
reactiveStateValue,
reactiveMetaIsTouched,
reactiveMetaIsBlurred,
Expand All @@ -333,18 +346,6 @@ export function useField<
fieldApi.update(opts)
})

useStore(
fieldApi.store,
opts.mode === 'array'
? (state) => {
return [
state.meta,
Object.keys((state.value as unknown) ?? []).length,
]
}
: undefined,
)

return extendedFieldApi
}

Expand Down
78 changes: 78 additions & 0 deletions packages/react-form/tests/useField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1226,6 +1226,84 @@ describe('useField', () => {
expect(renderCount.field2).toBe(field2InitialRender)
})

it('should not rerender array field when child field value changes', async () => {
// Test for https://github.com/TanStack/form/issues/1925
// Array fields should only re-render when the array length changes,
// not when a property on an array element is mutated
const renderCount = {
arrayField: 0,
childField: 0,
}

function Comp() {
const form = useForm({
defaultValues: {
people: [{ name: 'John' }, { name: 'Jane' }],
},
})

return (
<form.Field name="people" mode="array">
{(arrayField) => {
renderCount.arrayField++
return (
<div>
{arrayField.state.value.map((_, i) => (
<form.Field key={i} name={`people[${i}].name`}>
{(field) => {
if (i === 0) renderCount.childField++
return (
<input
data-testid={`person-${i}`}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
)
}}
</form.Field>
))}
<button
type="button"
data-testid="add-person"
onClick={() => arrayField.pushValue({ name: '' })}
>
Add
</button>
</div>
)
}}
</form.Field>
)
}

const { getByTestId } = render(
<StrictMode>
<Comp />
</StrictMode>,
)

const arrayFieldInitialRender = renderCount.arrayField
const childFieldInitialRender = renderCount.childField

// Type into the first child field
await user.type(getByTestId('person-0'), 'ny')

// Child field should have rerendered
expect(renderCount.childField).toBeGreaterThan(childFieldInitialRender)
// Array field should NOT have rerendered (this was the bug in #1925)
expect(renderCount.arrayField).toBe(arrayFieldInitialRender)

// Verify typing still works
expect(getByTestId('person-0')).toHaveValue('Johnny')

// Now add a new item - this SHOULD trigger array field re-render
const arrayFieldBeforeAdd = renderCount.arrayField
await user.click(getByTestId('add-person'))

// Array field should have rerendered when length changes
expect(renderCount.arrayField).toBeGreaterThan(arrayFieldBeforeAdd)
})

it('should handle defaultValue without setstate-in-render error', async () => {
// Spy on console.error before rendering
const consoleErrorSpy = vi.spyOn(console, 'error')
Expand Down
Loading