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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/fix-double-render-keystroke.md
Original file line number Diff line number Diff line change
@@ -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
34 changes: 23 additions & 11 deletions packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1728,21 +1728,30 @@ 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
*/
const validatesPromises: Promise<ValidationError | undefined>[] = []
const linkedPromises: Promise<ValidationError | undefined>[] = []

// 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<any>,
Expand Down Expand Up @@ -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)
Expand Down
41 changes: 41 additions & 0 deletions packages/form-core/tests/FieldApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading