diff --git a/.changeset/fix-double-render-keystroke.md b/.changeset/fix-double-render-keystroke.md new file mode 100644 index 000000000..477ab1cde --- /dev/null +++ b/.changeset/fix-double-render-keystroke.md @@ -0,0 +1,9 @@ +--- +'@tanstack/form-core': patch +--- + +fix: prevent unnecessary re-renders when there are no async validators + +Fields were re-rendering twice on each keystroke because `isValidating` was being set to `true` then `false` even when there were no async validators to run. This fix checks if there are actual async validators before toggling the `isValidating` state. + +Fixes #1130 diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 0d08bb9f4..d036e32c5 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1728,14 +1728,6 @@ export class FieldApi< >, ) - if (!this.state.meta.isValidating) { - this.setMeta((prev) => ({ ...prev, isValidating: true })) - } - - for (const linkedField of linkedFields) { - linkedField.setMeta((prev) => ({ ...prev, isValidating: true })) - } - /** * We have to use a for loop and generate our promises this way, otherwise it won't be sync * when there are no validators needed to be run @@ -1743,6 +1735,23 @@ export class FieldApi< const validatesPromises: Promise[] = [] const linkedPromises: Promise[] = [] + // Check if there are actual async validators to run before setting isValidating + // This prevents unnecessary re-renders when there are no async validators + // See: https://github.com/TanStack/form/issues/1130 + const hasAsyncValidators = + validates.some((v) => v.validate) || + linkedFieldValidates.some((v) => v.validate) + + if (hasAsyncValidators) { + if (!this.state.meta.isValidating) { + this.setMeta((prev) => ({ ...prev, isValidating: true })) + } + + for (const linkedField of linkedFields) { + linkedField.setMeta((prev) => ({ ...prev, isValidating: true })) + } + } + const validateFieldAsyncFn = ( field: AnyFieldApi, validateObj: AsyncValidator, @@ -1845,10 +1854,13 @@ export class FieldApi< await Promise.all(linkedPromises) } - this.setMeta((prev) => ({ ...prev, isValidating: false })) + // Only reset isValidating if we set it to true earlier + if (hasAsyncValidators) { + this.setMeta((prev) => ({ ...prev, isValidating: false })) - for (const linkedField of linkedFields) { - linkedField.setMeta((prev) => ({ ...prev, isValidating: false })) + for (const linkedField of linkedFields) { + linkedField.setMeta((prev) => ({ ...prev, isValidating: false })) + } } return results.filter(Boolean) diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 320ac1aea..0f3c296a9 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -797,6 +797,47 @@ describe('field api', () => { expect(field.getMeta().errors.length).toBe(0) }) + it('should not toggle isValidating when there are no async validators', async () => { + // Test for https://github.com/TanStack/form/issues/1130 + // Fields were re-rendering twice on each keystroke because isValidating + // was being set to true then false even when there were no async validators + vi.useFakeTimers() + + const form = new FormApi({ + defaultValues: { + name: 'test', + }, + }) + + form.mount() + + const field = new FieldApi({ + form, + name: 'name', + // No async validators defined - only sync or none + }) + + field.mount() + + // Track isValidating changes + const isValidatingStates: boolean[] = [] + field.store.subscribe(() => { + isValidatingStates.push(field.getMeta().isValidating) + }) + + // Initial state + expect(field.getMeta().isValidating).toBe(false) + + // Trigger validation by changing value + field.setValue('new value') + await vi.runAllTimersAsync() + + // isValidating should never have been set to true since there are no async validators + // This prevents unnecessary re-renders + expect(isValidatingStates.every((state) => state === false)).toBe(true) + expect(field.getMeta().isValidating).toBe(false) + }) + it('should run async validation onChange', async () => { vi.useFakeTimers()