Skip to content

Commit 35921c9

Browse files
fix(react-form): prevent array field re-render when child property changes (#1930)
* 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 root cause was that `reactiveStateValue` subscribed to the entire `state.value`, which changes whenever any child property changes. For array mode, this subscription now only tracks the array length, ensuring re-renders only occur when items are added or removed. - Modified `reactiveStateValue` to use array length selector for array mode - Updated `state.value` getter to return actual value from `fieldApi.state.value` - Removed redundant `useStore` subscription for array mode - Added test case to verify array field doesn't re-render on child changes Fixes #1925 * ci: apply automated fixes and generate docs --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent b372d43 commit 35921c9

File tree

3 files changed

+104
-14
lines changed

3 files changed

+104
-14
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@tanstack/react-form': patch
3+
---
4+
5+
fix(react-form): prevent array field re-render when child property changes
6+
7+
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.
8+
9+
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.
10+
11+
Fixes #1925

packages/react-form/src/useField.tsx

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,16 @@ export function useField<
221221
setPrevOptions({ form: opts.form, name: opts.name })
222222
}
223223

224-
const reactiveStateValue = useStore(fieldApi.store, (state) => state.value)
224+
// For array mode, only track length changes to avoid re-renders when child properties change
225+
// See: https://github.com/TanStack/form/issues/1925
226+
const reactiveStateValue = useStore(
227+
fieldApi.store,
228+
(opts.mode === 'array'
229+
? (state) => Object.keys((state.value as unknown) ?? []).length
230+
: (state) => state.value) as (
231+
state: typeof fieldApi.state,
232+
) => TData | number,
233+
)
225234
const reactiveMetaIsTouched = useStore(
226235
fieldApi.store,
227236
(state) => state.meta.isTouched,
@@ -253,7 +262,10 @@ export function useField<
253262
...fieldApi,
254263
get state() {
255264
return {
256-
value: reactiveStateValue,
265+
// For array mode, reactiveStateValue is the length (for reactivity tracking),
266+
// so we need to get the actual value from fieldApi
267+
value:
268+
opts.mode === 'array' ? fieldApi.state.value : reactiveStateValue,
257269
get meta() {
258270
return {
259271
...fieldApi.state.meta,
@@ -314,6 +326,7 @@ export function useField<
314326
return extendedApi
315327
}, [
316328
fieldApi,
329+
opts.mode,
317330
reactiveStateValue,
318331
reactiveMetaIsTouched,
319332
reactiveMetaIsBlurred,
@@ -333,18 +346,6 @@ export function useField<
333346
fieldApi.update(opts)
334347
})
335348

336-
useStore(
337-
fieldApi.store,
338-
opts.mode === 'array'
339-
? (state) => {
340-
return [
341-
state.meta,
342-
Object.keys((state.value as unknown) ?? []).length,
343-
]
344-
}
345-
: undefined,
346-
)
347-
348349
return extendedFieldApi
349350
}
350351

packages/react-form/tests/useField.test.tsx

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1226,6 +1226,84 @@ describe('useField', () => {
12261226
expect(renderCount.field2).toBe(field2InitialRender)
12271227
})
12281228

1229+
it('should not rerender array field when child field value changes', async () => {
1230+
// Test for https://github.com/TanStack/form/issues/1925
1231+
// Array fields should only re-render when the array length changes,
1232+
// not when a property on an array element is mutated
1233+
const renderCount = {
1234+
arrayField: 0,
1235+
childField: 0,
1236+
}
1237+
1238+
function Comp() {
1239+
const form = useForm({
1240+
defaultValues: {
1241+
people: [{ name: 'John' }, { name: 'Jane' }],
1242+
},
1243+
})
1244+
1245+
return (
1246+
<form.Field name="people" mode="array">
1247+
{(arrayField) => {
1248+
renderCount.arrayField++
1249+
return (
1250+
<div>
1251+
{arrayField.state.value.map((_, i) => (
1252+
<form.Field key={i} name={`people[${i}].name`}>
1253+
{(field) => {
1254+
if (i === 0) renderCount.childField++
1255+
return (
1256+
<input
1257+
data-testid={`person-${i}`}
1258+
value={field.state.value}
1259+
onChange={(e) => field.handleChange(e.target.value)}
1260+
/>
1261+
)
1262+
}}
1263+
</form.Field>
1264+
))}
1265+
<button
1266+
type="button"
1267+
data-testid="add-person"
1268+
onClick={() => arrayField.pushValue({ name: '' })}
1269+
>
1270+
Add
1271+
</button>
1272+
</div>
1273+
)
1274+
}}
1275+
</form.Field>
1276+
)
1277+
}
1278+
1279+
const { getByTestId } = render(
1280+
<StrictMode>
1281+
<Comp />
1282+
</StrictMode>,
1283+
)
1284+
1285+
const arrayFieldInitialRender = renderCount.arrayField
1286+
const childFieldInitialRender = renderCount.childField
1287+
1288+
// Type into the first child field
1289+
await user.type(getByTestId('person-0'), 'ny')
1290+
1291+
// Child field should have rerendered
1292+
expect(renderCount.childField).toBeGreaterThan(childFieldInitialRender)
1293+
// Array field should NOT have rerendered (this was the bug in #1925)
1294+
expect(renderCount.arrayField).toBe(arrayFieldInitialRender)
1295+
1296+
// Verify typing still works
1297+
expect(getByTestId('person-0')).toHaveValue('Johnny')
1298+
1299+
// Now add a new item - this SHOULD trigger array field re-render
1300+
const arrayFieldBeforeAdd = renderCount.arrayField
1301+
await user.click(getByTestId('add-person'))
1302+
1303+
// Array field should have rerendered when length changes
1304+
expect(renderCount.arrayField).toBeGreaterThan(arrayFieldBeforeAdd)
1305+
})
1306+
12291307
it('should handle defaultValue without setstate-in-render error', async () => {
12301308
// Spy on console.error before rendering
12311309
const consoleErrorSpy = vi.spyOn(console, 'error')

0 commit comments

Comments
 (0)