Skip to content

Commit 2d76c2f

Browse files
committed
feat(FieldApi): allow debounce of onChange and onBlur listener
1 parent 6ebf2f7 commit 2d76c2f

File tree

2 files changed

+118
-32
lines changed

2 files changed

+118
-32
lines changed

Diff for: packages/form-core/src/FieldApi.ts

+54-32
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,9 @@ export interface FieldListeners<
349349
TData extends DeepValue<TParentData, TName> = DeepValue<TParentData, TName>,
350350
> {
351351
onChange?: FieldListenerFn<TParentData, TName, TData>
352+
onChangeDebounceMs?: number
352353
onBlur?: FieldListenerFn<TParentData, TName, TData>
354+
onBlurDebounceMs?: number
353355
onMount?: FieldListenerFn<TParentData, TName, TData>
354356
onSubmit?: FieldListenerFn<TParentData, TName, TData>
355357
}
@@ -1175,10 +1177,7 @@ export class FieldApi<
11751177
setValue = (updater: Updater<TData>, options?: UpdateMetaOptions) => {
11761178
this.form.setFieldValue(this.name, updater as never, options)
11771179

1178-
this.options.listeners?.onChange?.({
1179-
value: this.state.value,
1180-
fieldApi: this,
1181-
})
1180+
this.triggerOnChangeListener()
11821181

11831182
this.validate('change')
11841183
}
@@ -1226,10 +1225,7 @@ export class FieldApi<
12261225
) => {
12271226
this.form.pushFieldValue(this.name, value as any, opts)
12281227

1229-
this.options.listeners?.onChange?.({
1230-
value: this.state.value,
1231-
fieldApi: this,
1232-
})
1228+
this.triggerOnChangeListener()
12331229
}
12341230

12351231
/**
@@ -1242,10 +1238,7 @@ export class FieldApi<
12421238
) => {
12431239
this.form.insertFieldValue(this.name, index, value as any, opts)
12441240

1245-
this.options.listeners?.onChange?.({
1246-
value: this.state.value,
1247-
fieldApi: this,
1248-
})
1241+
this.triggerOnChangeListener()
12491242
}
12501243

12511244
/**
@@ -1258,10 +1251,7 @@ export class FieldApi<
12581251
) => {
12591252
this.form.replaceFieldValue(this.name, index, value as any, opts)
12601253

1261-
this.options.listeners?.onChange?.({
1262-
value: this.state.value,
1263-
fieldApi: this,
1264-
})
1254+
this.triggerOnChangeListener()
12651255
}
12661256

12671257
/**
@@ -1270,10 +1260,7 @@ export class FieldApi<
12701260
removeValue = (index: number, opts?: UpdateMetaOptions) => {
12711261
this.form.removeFieldValue(this.name, index, opts)
12721262

1273-
this.options.listeners?.onChange?.({
1274-
value: this.state.value,
1275-
fieldApi: this,
1276-
})
1263+
this.triggerOnChangeListener()
12771264
}
12781265

12791266
/**
@@ -1282,10 +1269,7 @@ export class FieldApi<
12821269
swapValues = (aIndex: number, bIndex: number, opts?: UpdateMetaOptions) => {
12831270
this.form.swapFieldValues(this.name, aIndex, bIndex, opts)
12841271

1285-
this.options.listeners?.onChange?.({
1286-
value: this.state.value,
1287-
fieldApi: this,
1288-
})
1272+
this.triggerOnChangeListener()
12891273
}
12901274

12911275
/**
@@ -1294,10 +1278,7 @@ export class FieldApi<
12941278
moveValue = (aIndex: number, bIndex: number, opts?: UpdateMetaOptions) => {
12951279
this.form.moveFieldValues(this.name, aIndex, bIndex, opts)
12961280

1297-
this.options.listeners?.onChange?.({
1298-
value: this.state.value,
1299-
fieldApi: this,
1300-
})
1281+
this.triggerOnChangeListener()
13011282
}
13021283

13031284
/**
@@ -1633,10 +1614,7 @@ export class FieldApi<
16331614
}
16341615
this.validate('blur')
16351616

1636-
this.options.listeners?.onBlur?.({
1637-
value: this.state.value,
1638-
fieldApi: this,
1639-
})
1617+
this.triggerOnBlurListener()
16401618
}
16411619

16421620
/**
@@ -1654,6 +1632,50 @@ export class FieldApi<
16541632
}) as never,
16551633
)
16561634
}
1635+
1636+
private triggerOnBlurListener() {
1637+
const debounceMs = this.options.listeners?.onBlurDebounceMs
1638+
1639+
if (debounceMs && debounceMs > 0) {
1640+
if (this.timeoutIds.blur) {
1641+
clearTimeout(this.timeoutIds.blur)
1642+
}
1643+
1644+
this.timeoutIds.blur = setTimeout(() => {
1645+
this.options.listeners?.onBlur?.({
1646+
value: this.state.value,
1647+
fieldApi: this,
1648+
})
1649+
}, debounceMs)
1650+
} else {
1651+
this.options.listeners?.onBlur?.({
1652+
value: this.state.value,
1653+
fieldApi: this,
1654+
})
1655+
}
1656+
}
1657+
1658+
private triggerOnChangeListener() {
1659+
const debounceMs = this.options.listeners?.onChangeDebounceMs
1660+
1661+
if (debounceMs && debounceMs > 0) {
1662+
if (this.timeoutIds.change) {
1663+
clearTimeout(this.timeoutIds.change)
1664+
}
1665+
1666+
this.timeoutIds.change = setTimeout(() => {
1667+
this.options.listeners?.onChange?.({
1668+
value: this.state.value,
1669+
fieldApi: this,
1670+
})
1671+
}, debounceMs)
1672+
} else {
1673+
this.options.listeners?.onChange?.({
1674+
value: this.state.value,
1675+
fieldApi: this,
1676+
})
1677+
}
1678+
}
16571679
}
16581680

16591681
function normalizeError(rawError?: ValidationError) {

Diff for: packages/form-core/tests/FieldApi.spec.ts

+64
Original file line numberDiff line numberDiff line change
@@ -1890,4 +1890,68 @@ describe('field api', () => {
18901890
expect(field.getMeta().errors).toStrictEqual([])
18911891
expect(form.state.canSubmit).toBe(true)
18921892
})
1893+
1894+
it('should debounce onChange listener', async () => {
1895+
vi.useFakeTimers()
1896+
const form = new FormApi({
1897+
defaultValues: {
1898+
name: '',
1899+
},
1900+
})
1901+
1902+
form.mount()
1903+
1904+
const onChangeMock = vi.fn()
1905+
const field = new FieldApi({
1906+
form,
1907+
name: 'name',
1908+
listeners: {
1909+
onChange: onChangeMock,
1910+
onChangeDebounceMs: 500,
1911+
},
1912+
})
1913+
1914+
field.mount()
1915+
1916+
field.handleChange('first')
1917+
field.handleChange('second')
1918+
expect(onChangeMock).not.toHaveBeenCalled()
1919+
1920+
await vi.advanceTimersByTimeAsync(500)
1921+
expect(onChangeMock).toHaveBeenCalledTimes(1)
1922+
expect(onChangeMock).toHaveBeenCalledWith({
1923+
value: 'second',
1924+
fieldApi: field,
1925+
})
1926+
})
1927+
1928+
it('should debounce onBlur listener', async () => {
1929+
vi.useFakeTimers()
1930+
const form = new FormApi({
1931+
defaultValues: {
1932+
name: '',
1933+
},
1934+
})
1935+
1936+
form.mount()
1937+
1938+
const onBlurMock = vi.fn()
1939+
const field = new FieldApi({
1940+
form,
1941+
name: 'name',
1942+
listeners: {
1943+
onBlur: onBlurMock,
1944+
onBlurDebounceMs: 300,
1945+
},
1946+
})
1947+
1948+
field.mount()
1949+
1950+
field.handleBlur()
1951+
field.handleBlur()
1952+
expect(onBlurMock).not.toHaveBeenCalled()
1953+
1954+
await vi.advanceTimersByTimeAsync(300)
1955+
expect(onBlurMock).toHaveBeenCalledTimes(1)
1956+
})
18931957
})

0 commit comments

Comments
 (0)