From cdf93510aee931a512d131711b353a39eff344e0 Mon Sep 17 00:00:00 2001 From: Isaac Kaara Date: Tue, 10 Mar 2026 14:16:42 +0300 Subject: [PATCH] fix(react-form): re-render mode="array" fields after swapValues/moveValue The array-length-only selector (introduced in #1930 to fix #1925) prevented re-renders when items were swapped or moved because the array length didn't change. A small useReducer version counter now bumps on each swapValues/ moveValue call, forcing a re-render so the displayed order stays in sync. Fixes #2018 --- .changeset/fix-array-swap-rerender.md | 16 +++++ packages/react-form/src/useField.tsx | 32 ++++++++- packages/react-form/tests/useField.test.tsx | 76 +++++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-array-swap-rerender.md diff --git a/.changeset/fix-array-swap-rerender.md b/.changeset/fix-array-swap-rerender.md new file mode 100644 index 000000000..74821d4dc --- /dev/null +++ b/.changeset/fix-array-swap-rerender.md @@ -0,0 +1,16 @@ +--- +'@tanstack/react-form': patch +--- + +fix(react-form): re-render mode="array" fields after swapValues/moveValue + +When `swapValues` or `moveValue` was called on a `mode="array"` field, the +array field did not re-render because the array length didn't change and the +selector only tracked length (to avoid re-renders on child property changes, +see #1925). + +The fix introduces a small version counter via `useReducer` that bumps +whenever `swapValues` or `moveValue` is called. This forces a re-render so +the displayed item order stays in sync with the form state. + +Fixes #2018 diff --git a/packages/react-form/src/useField.tsx b/packages/react-form/src/useField.tsx index 2a3f546cf..ccc54e505 100644 --- a/packages/react-form/src/useField.tsx +++ b/packages/react-form/src/useField.tsx @@ -1,6 +1,6 @@ 'use client' -import { useMemo, useRef, useState } from 'react' +import { useMemo, useReducer, useRef, useState } from 'react' import { useStore } from '@tanstack/react-store' import { FieldApi, functionalUpdate } from '@tanstack/form-core' import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' @@ -221,6 +221,14 @@ export function useField< setPrevOptions({ form: opts.form, name: opts.name }) } + // For array mode, track a version counter that bumps on structural operations + // (swapValues/moveValue) so re-orders trigger re-renders even when length stays the same. + // See: https://github.com/TanStack/form/issues/2018 + const [arrayStructuralVersion, bumpArrayStructuralVersion] = useReducer( + (v: number) => v + 1, + 0, + ) + // 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( @@ -323,6 +331,26 @@ export function useField< extendedApi.Field = Field as never + // Wrap swapValues and moveValue for mode="array" so that re-ordering + // triggers a re-render even when the array length doesn't change. + // See: https://github.com/TanStack/form/issues/2018 + if (opts.mode === 'array') { + const originalSwapValues = extendedApi.swapValues.bind(extendedApi) + extendedApi.swapValues = ( + ...args: Parameters + ) => { + originalSwapValues(...args) + bumpArrayStructuralVersion() + } + const originalMoveValue = extendedApi.moveValue.bind(extendedApi) + extendedApi.moveValue = ( + ...args: Parameters + ) => { + originalMoveValue(...args) + bumpArrayStructuralVersion() + } + } + return extendedApi }, [ fieldApi, @@ -334,6 +362,8 @@ export function useField< reactiveMetaErrorMap, reactiveMetaErrorSourceMap, reactiveMetaIsValidating, + arrayStructuralVersion, + bumpArrayStructuralVersion, ]) useIsomorphicLayoutEffect(fieldApi.mount, [fieldApi]) diff --git a/packages/react-form/tests/useField.test.tsx b/packages/react-form/tests/useField.test.tsx index 861ad9df5..c922efb33 100644 --- a/packages/react-form/tests/useField.test.tsx +++ b/packages/react-form/tests/useField.test.tsx @@ -1304,6 +1304,82 @@ describe('useField', () => { expect(renderCount.arrayField).toBeGreaterThan(arrayFieldBeforeAdd) }) + it('should rerender array field when swapValues or moveValue is called', async () => { + // Test for https://github.com/TanStack/form/issues/2018 + // swapValues and moveValue don't change array length, but the displayed + // order should update — meaning the array field must re-render. + let arrayFieldRenderCount = 0 + + function Comp() { + const form = useForm({ + defaultValues: { + fruits: ['apple', 'banana', 'cherry'], + }, + }) + + return ( + + {(field) => { + arrayFieldRenderCount++ + return ( +
+
    + {field.state.value.map((fruit, i) => ( +
  • + {fruit} +
  • + ))} +
+ + +
+ ) + }} +
+ ) + } + + const { getByTestId } = render( + + + , + ) + + const initialRender = arrayFieldRenderCount + + // Swap first and last item — length stays the same but order changes + await user.click(getByTestId('swap')) + + await waitFor(() => { + expect(arrayFieldRenderCount).toBeGreaterThan(initialRender) + expect(getByTestId('item-0')).toHaveTextContent('cherry') + expect(getByTestId('item-2')).toHaveTextContent('apple') + }) + + const afterSwapRender = arrayFieldRenderCount + + // Move first item to index 1 — again, no length change + await user.click(getByTestId('move')) + + await waitFor(() => { + expect(arrayFieldRenderCount).toBeGreaterThan(afterSwapRender) + expect(getByTestId('item-0')).toHaveTextContent('banana') + expect(getByTestId('item-1')).toHaveTextContent('cherry') + }) + }) + it('should handle defaultValue without setstate-in-render error', async () => { // Spy on console.error before rendering const consoleErrorSpy = vi.spyOn(console, 'error')