Skip to content

Commit cc06a6b

Browse files
feat(core): debounced form listeners (#1463)
1 parent 9f68452 commit cc06a6b

File tree

4 files changed

+109
-15
lines changed

4 files changed

+109
-15
lines changed

docs/framework/react/guides/listeners.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ We enable an easy method for debouncing your listeners by adding a `onChangeDebo
9292

9393
### Form listeners
9494

95-
At a higher level, listeners are also available at the form level, allowing you access to the `onMount` and `onSubmit` events, and having `onChange` and `onBlur` propagated to all the form's children.
95+
At a higher level, listeners are also available at the form level, allowing you access to the `onMount` and `onSubmit` events, and having `onChange` and `onBlur` propagated to all the form's children. Form-level listeners can also be debounced in the same way as previously discussed.
9696

9797
`onMount` and `onSubmit` listeners have to following props:
9898

@@ -120,6 +120,7 @@ const form = useForm({
120120
// fieldApi represents the field that triggered the event.
121121
console.log(fieldApi.name, fieldApi.state.value)
122122
},
123+
onChangeDebounceMs: 500,
123124
},
124125
})
125126
```

packages/form-core/src/FieldApi.ts

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -978,6 +978,7 @@ export class FieldApi<
978978
timeoutIds: {
979979
validations: Record<ValidationCause, ReturnType<typeof setTimeout> | null>
980980
listeners: Record<ListenerCause, ReturnType<typeof setTimeout> | null>
981+
formListeners: Record<ListenerCause, ReturnType<typeof setTimeout> | null>
981982
}
982983

983984
/**
@@ -1011,6 +1012,7 @@ export class FieldApi<
10111012
this.timeoutIds = {
10121013
validations: {} as Record<ValidationCause, never>,
10131014
listeners: {} as Record<ListenerCause, never>,
1015+
formListeners: {} as Record<ListenerCause, never>,
10141016
}
10151017

10161018
this.store = new Derived({
@@ -1703,13 +1705,27 @@ export class FieldApi<
17031705
}
17041706

17051707
private triggerOnBlurListener() {
1706-
const debounceMs = this.options.listeners?.onBlurDebounceMs
1707-
this.form.options.listeners?.onBlur?.({
1708-
formApi: this.form,
1709-
fieldApi: this,
1710-
})
1708+
const formDebounceMs = this.form.options.listeners?.onBlurDebounceMs
1709+
if (formDebounceMs && formDebounceMs > 0) {
1710+
if (this.timeoutIds.formListeners.blur) {
1711+
clearTimeout(this.timeoutIds.formListeners.blur)
1712+
}
1713+
1714+
this.timeoutIds.formListeners.blur = setTimeout(() => {
1715+
this.form.options.listeners?.onBlur?.({
1716+
formApi: this.form,
1717+
fieldApi: this,
1718+
})
1719+
}, formDebounceMs)
1720+
} else {
1721+
this.form.options.listeners?.onBlur?.({
1722+
formApi: this.form,
1723+
fieldApi: this,
1724+
})
1725+
}
17111726

1712-
if (debounceMs && debounceMs > 0) {
1727+
const fieldDebounceMs = this.options.listeners?.onBlurDebounceMs
1728+
if (fieldDebounceMs && fieldDebounceMs > 0) {
17131729
if (this.timeoutIds.listeners.blur) {
17141730
clearTimeout(this.timeoutIds.listeners.blur)
17151731
}
@@ -1719,7 +1735,7 @@ export class FieldApi<
17191735
value: this.state.value,
17201736
fieldApi: this,
17211737
})
1722-
}, debounceMs)
1738+
}, fieldDebounceMs)
17231739
} else {
17241740
this.options.listeners?.onBlur?.({
17251741
value: this.state.value,
@@ -1729,14 +1745,27 @@ export class FieldApi<
17291745
}
17301746

17311747
private triggerOnChangeListener() {
1732-
const debounceMs = this.options.listeners?.onChangeDebounceMs
1748+
const formDebounceMs = this.form.options.listeners?.onChangeDebounceMs
1749+
if (formDebounceMs && formDebounceMs > 0) {
1750+
if (this.timeoutIds.formListeners.blur) {
1751+
clearTimeout(this.timeoutIds.formListeners.blur)
1752+
}
17331753

1734-
this.form.options.listeners?.onChange?.({
1735-
formApi: this.form,
1736-
fieldApi: this,
1737-
})
1754+
this.timeoutIds.formListeners.blur = setTimeout(() => {
1755+
this.form.options.listeners?.onChange?.({
1756+
formApi: this.form,
1757+
fieldApi: this,
1758+
})
1759+
}, formDebounceMs)
1760+
} else {
1761+
this.form.options.listeners?.onChange?.({
1762+
formApi: this.form,
1763+
fieldApi: this,
1764+
})
1765+
}
17381766

1739-
if (debounceMs && debounceMs > 0) {
1767+
const fieldDebounceMs = this.options.listeners?.onChangeDebounceMs
1768+
if (fieldDebounceMs && fieldDebounceMs > 0) {
17401769
if (this.timeoutIds.listeners.change) {
17411770
clearTimeout(this.timeoutIds.listeners.change)
17421771
}
@@ -1746,7 +1775,7 @@ export class FieldApi<
17461775
value: this.state.value,
17471776
fieldApi: this,
17481777
})
1749-
}, debounceMs)
1778+
}, fieldDebounceMs)
17501779
} else {
17511780
this.options.listeners?.onChange?.({
17521781
value: this.state.value,

packages/form-core/src/FormApi.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ export interface FormListeners<
265265
>
266266
fieldApi: AnyFieldApi
267267
}) => void
268+
onChangeDebounceMs?: number
268269

269270
onBlur?: (props: {
270271
formApi: FormApi<
@@ -281,6 +282,7 @@ export interface FormListeners<
281282
>
282283
fieldApi: AnyFieldApi
283284
}) => void
285+
onBlurDebounceMs?: number
284286

285287
onMount?: (props: {
286288
formApi: FormApi<

packages/form-core/tests/FormApi.spec.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2064,6 +2064,39 @@ describe('form api', () => {
20642064
expect(arr).toStrictEqual(['middle', 'end', 'start'])
20652065
})
20662066

2067+
it('should debounce onChange listener', async () => {
2068+
vi.useFakeTimers()
2069+
const onChangeMock = vi.fn()
2070+
2071+
const form = new FormApi({
2072+
defaultValues: {
2073+
name: '',
2074+
},
2075+
listeners: {
2076+
onChange: onChangeMock,
2077+
onChangeDebounceMs: 500,
2078+
},
2079+
})
2080+
form.mount()
2081+
2082+
const field = new FieldApi({
2083+
form,
2084+
name: 'name',
2085+
})
2086+
field.mount()
2087+
2088+
field.handleChange('first')
2089+
field.handleChange('second')
2090+
expect(onChangeMock).not.toHaveBeenCalled()
2091+
2092+
await vi.advanceTimersByTimeAsync(500)
2093+
expect(onChangeMock).toHaveBeenCalledTimes(1)
2094+
expect(onChangeMock).toHaveBeenCalledWith({
2095+
formApi: form,
2096+
fieldApi: field,
2097+
})
2098+
})
2099+
20672100
it('should run the form listener onBlur', async () => {
20682101
let fieldApiCheck!: AnyFieldApi
20692102
let formApiCheck!: AnyFormApi
@@ -2094,6 +2127,35 @@ describe('form api', () => {
20942127
expect(formApiCheck.state.values.name).toStrictEqual('test')
20952128
})
20962129

2130+
it('should debounce onBlur listener', async () => {
2131+
vi.useFakeTimers()
2132+
const onBlurMock = vi.fn()
2133+
2134+
const form = new FormApi({
2135+
defaultValues: {
2136+
name: '',
2137+
},
2138+
listeners: {
2139+
onBlur: onBlurMock,
2140+
onBlurDebounceMs: 500,
2141+
},
2142+
})
2143+
form.mount()
2144+
2145+
const field = new FieldApi({
2146+
form,
2147+
name: 'name',
2148+
})
2149+
field.mount()
2150+
2151+
field.handleBlur()
2152+
field.handleBlur()
2153+
expect(onBlurMock).not.toHaveBeenCalled()
2154+
2155+
await vi.advanceTimersByTimeAsync(500)
2156+
expect(onBlurMock).toHaveBeenCalledTimes(1)
2157+
})
2158+
20972159
it('should run the field listener onSubmit', async () => {
20982160
const form = new FormApi({
20992161
defaultValues: {

0 commit comments

Comments
 (0)