From cf2993f1d63794b451274cfad5a9562c59c1234f Mon Sep 17 00:00:00 2001 From: Bar M <32390017+galshir@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:55:38 +0300 Subject: [PATCH 1/9] feat(form-core): thread prevValue through the listener pipelines --- packages/form-core/src/listeners.lib.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/form-core/src/listeners.lib.ts b/packages/form-core/src/listeners.lib.ts index 251b87220..67013a48b 100644 --- a/packages/form-core/src/listeners.lib.ts +++ b/packages/form-core/src/listeners.lib.ts @@ -19,12 +19,16 @@ type FormInputContext = { event: ListenerEvent triggerFieldApi?: AnyInternalFieldApi formApi: InternalFormApi + /** The form values before the change that caused the event. */ + prevValue?: unknown } type FieldInputContext = { event: ListenerEvent fieldApi: AnyInternalFieldApi formApi: InternalFormApi + /** The listening field's value before the change that caused the event. */ + prevValue?: unknown } type InputContext = FormInputContext | FieldInputContext @@ -192,6 +196,7 @@ export function runFormListenerPipeline({ formApi: ctx.formApi, triggerFieldApi: ctx.triggerFieldApi, value: ctx.formApi.state.values, + prevValue: ctx.prevValue, }), }) } @@ -222,6 +227,7 @@ export function runFieldListenerPipeline({ value: ctx.fieldApi.value, fieldApi: context.fieldApi, formApi: ctx.formApi, + prevValue: ctx.prevValue, }), }) } From ffb707aa572e77afe0bd9122491e790447b30b99 Mon Sep 17 00:00:00 2001 From: Bar M <32390017+galshir@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:56:20 +0300 Subject: [PATCH 2/9] feat(form-core): add prevValue to the public listener contexts --- packages/form-core/src/listeners.public.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/form-core/src/listeners.public.ts b/packages/form-core/src/listeners.public.ts index ba1c98fd5..58eb31ae8 100644 --- a/packages/form-core/src/listeners.public.ts +++ b/packages/form-core/src/listeners.public.ts @@ -217,6 +217,15 @@ export interface FormListenerContext< formApi: FormApi /** The form values captured when the event occurred. */ value: TFormData + /** + * The form values immediately before the change that invoked the listener. + * + * Only provided for `'change'` events, and `undefined` for every other + * trigger. When the listener is debounced, this is the value from before + * the change that ultimately invokes it, not from before the first change + * of the burst. + */ + prevValue?: TFormData } /** @@ -345,6 +354,16 @@ export interface FieldListenerContext< > { /** The listening field's value captured when the event occurred. */ value: TFieldValue + /** + * The listening field's value immediately before the change that invoked + * the listener. + * + * Only provided for `'change'` events, and `undefined` for every other + * trigger. When the listener is debounced, this is the value from before + * the change that ultimately invokes it, not from before the first change + * of the burst. + */ + prevValue?: TFieldValue /** * The field that owns the listener. * From 21d842d199bc6a14a0bf3d1d0a272480b5586783 Mon Sep 17 00:00:00 2001 From: Bar M <32390017+galshir@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:57:13 +0300 Subject: [PATCH 3/9] feat(form-core): capture previous form values on field change --- packages/form-core/src/FormApi/FormApi.lib.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/form-core/src/FormApi/FormApi.lib.ts b/packages/form-core/src/FormApi/FormApi.lib.ts index 36969c27d..38ffe9f58 100644 --- a/packages/form-core/src/FormApi/FormApi.lib.ts +++ b/packages/form-core/src/FormApi/FormApi.lib.ts @@ -507,6 +507,7 @@ export class InternalFormApi< updateOptions.fieldApiOverride = field batch(() => { + const previousValues = this._atoms.values.get() const previousValue = this.getFieldValue(fieldName) const nextValue = callUpdater(updater, previousValue) const replacedSameLengthArray = @@ -524,7 +525,7 @@ export class InternalFormApi< })) } - this._notifyFieldChange(field, updateOptions) + this._notifyFieldChange(field, updateOptions, previousValues) }) notifyDevtoolsFieldValueUpdate(field) @@ -664,6 +665,7 @@ export class InternalFormApi< _notifyFieldChange( field: AnyInternalFieldApi | null, options: ResolvedInternalFieldUpdateOptions, + prevValues?: TFormData, ) { this._clearEventErrors(field, 'submit', 'change') this._clearEventErrors(field, 'server', 'change') @@ -674,13 +676,14 @@ export class InternalFormApi< this._atoms.meta.isDirty.set(true) } - field?._notifyEvent(options, 'change') - this._notifyFormListener('change', field) + field?._notifyEvent(options, 'change', prevValues) + this._notifyFormListener('change', field, prevValues) } _notifyFormListener( trigger: FormListenerTriggers, triggerFieldApi: AnyInternalFieldApi | null, + prevValue?: TFormData, ) { if (!this._listenerInstances) return @@ -690,6 +693,7 @@ export class InternalFormApi< event: trigger, formApi: this, triggerFieldApi: triggerFieldApi ?? undefined, + prevValue, }, }) } From 907635bf790c34694b204b335baea358fd042410 Mon Sep 17 00:00:00 2001 From: Bar M <32390017+galshir@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:57:40 +0300 Subject: [PATCH 4/9] feat(form-core): resolve the previous value for field listeners --- .../form-core/src/FieldApi/FieldApi.lib.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/form-core/src/FieldApi/FieldApi.lib.ts b/packages/form-core/src/FieldApi/FieldApi.lib.ts index 127e52ff0..e1d1e0b0f 100644 --- a/packages/form-core/src/FieldApi/FieldApi.lib.ts +++ b/packages/form-core/src/FieldApi/FieldApi.lib.ts @@ -968,6 +968,7 @@ export class InternalFieldApi< 'fieldApiOverride' | '_skipFieldCreation' >, event: 'change' | 'blur' | 'submit', + prevFormValues?: TFormData, ): void { if (this._isKilled) return @@ -1013,7 +1014,12 @@ export class InternalFieldApi< })) } - currNode._notifyListener(event, seenListenerFields) + currNode._notifyListener( + event, + seenListenerFields, + null, + prevFormValues, + ) if (!doPropagate) return stop return undefined @@ -1029,6 +1035,7 @@ export class InternalFieldApi< trigger: FieldListenerTriggers, seenFields: WeakSet, onlyRunListenerInstances: ReadonlySet | null = null, + prevFormValues?: TFormData, ) { if (this._isKilled) return @@ -1050,6 +1057,10 @@ export class InternalFieldApi< event: trigger, fieldApi: this, formApi: this.form, + prevValue: + prevFormValues === undefined + ? undefined + : getBy(prevFormValues, this.name), }, listenerInstancesToRun: onlyRunListenerInstances, }) @@ -1064,7 +1075,12 @@ export class InternalFieldApi< continue } - watchingField._notifyListener(trigger, seenFields, listenerInstances) + watchingField._notifyListener( + trigger, + seenFields, + listenerInstances, + prevFormValues, + ) } if (watchingFields.size === 0) { From 9b14e39e763ce7070b25223d7ece49111f89eaad Mon Sep 17 00:00:00 2001 From: Bar M <32390017+galshir@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:58:13 +0300 Subject: [PATCH 5/9] feat(form-core): keep prevValue defined for no-op array filters --- packages/form-core/src/FormApi/array-methods.lib.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/form-core/src/FormApi/array-methods.lib.ts b/packages/form-core/src/FormApi/array-methods.lib.ts index 55c86da77..8d477a1e5 100644 --- a/packages/form-core/src/FormApi/array-methods.lib.ts +++ b/packages/form-core/src/FormApi/array-methods.lib.ts @@ -349,7 +349,7 @@ function filterFieldValues({ if (oldArray.length === filtered.length) { // Setting filtered array would be a no-op, but either way the user // tried to set a value - form._notifyFieldChange(arrayNode, updateOptions) + form._notifyFieldChange(arrayNode, updateOptions, form.state.values) } else { form.setFieldValue(arrayFieldName, filtered, updateOptions) } From 03f32cde71ca3e41ccbf894a3092f9bd4a3201d3 Mon Sep 17 00:00:00 2001 From: Bar M <32390017+galshir@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:59:45 +0300 Subject: [PATCH 6/9] test(form-core): cover prevValue in form listener contexts --- .../form-core/tests/FormApi/listeners.spec.ts | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/packages/form-core/tests/FormApi/listeners.spec.ts b/packages/form-core/tests/FormApi/listeners.spec.ts index 863c23de1..cd85d2df5 100644 --- a/packages/form-core/tests/FormApi/listeners.spec.ts +++ b/packages/form-core/tests/FormApi/listeners.spec.ts @@ -17,6 +17,7 @@ describe('form - listeners', () => { formApi: form, triggerFieldApi: field, value: { name: 'Alice' }, + prevValue: { name: '' }, }) }) @@ -34,6 +35,7 @@ describe('form - listeners', () => { formApi: form, triggerFieldApi: form._tryGetFieldApi('name') ?? undefined, value: { name: 'Alice' }, + prevValue: { name: '' }, }) }) @@ -205,6 +207,7 @@ describe('form - listeners', () => { formApi: form, triggerFieldApi: field, value: { name: 'Alice' }, + prevValue: { name: '' }, }) vi.useRealTimers() @@ -254,6 +257,7 @@ describe('form - listeners', () => { formApi: form, triggerFieldApi: field, value: { name: 'Alice' }, + prevValue: { name: '' }, }) vi.useRealTimers() @@ -281,4 +285,81 @@ describe('form - listeners', () => { consoleSpy.mockRestore() }) + + it('provides the previous form values to change listeners', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [{ triggers: ['change'], run: listener }], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + field.handleChange('Alice') + field.handleChange('Bob') + + expect(listener).toHaveBeenCalledTimes(2) + expect(listener).toHaveBeenNthCalledWith(1, { + formApi: form, + triggerFieldApi: field, + value: { name: 'Alice' }, + prevValue: { name: '' }, + }) + expect(listener).toHaveBeenNthCalledWith(2, { + formApi: form, + triggerFieldApi: field, + value: { name: 'Bob' }, + prevValue: { name: 'Alice' }, + }) + }) + + it('does not provide a previous value for non-change form events', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [{ triggers: ['blur'], run: listener }], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + field.handleBlur() + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith({ + formApi: form, + triggerFieldApi: field, + value: { name: '' }, + prevValue: undefined, + }) + }) + + it('gives a debounced listener the value from before the invoking change', async () => { + vi.useFakeTimers() + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [ + { + triggers: ['change'], + triggerDebounceMs: 100, + run: listener, + }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + field.handleChange('A') + field.handleChange('Al') + field.handleChange('Ali') + + await vi.advanceTimersByTimeAsync(100) + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith({ + formApi: form, + triggerFieldApi: field, + value: { name: 'Ali' }, + prevValue: { name: 'Al' }, + }) + + vi.useRealTimers() + }) }) From 0a1b0eed179ed4d1037ba8b2a5f2c5c2816838f1 Mon Sep 17 00:00:00 2001 From: Bar M <32390017+galshir@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:01:08 +0300 Subject: [PATCH 7/9] test(form-core): cover prevValue in field listener contexts --- .../tests/FieldApi/listeners.spec.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/packages/form-core/tests/FieldApi/listeners.spec.ts b/packages/form-core/tests/FieldApi/listeners.spec.ts index 21abb2e0c..a6dafb476 100644 --- a/packages/form-core/tests/FieldApi/listeners.spec.ts +++ b/packages/form-core/tests/FieldApi/listeners.spec.ts @@ -15,6 +15,76 @@ describe('field - listeners', () => { expect(listener).toHaveBeenCalledOnce() }) + it('provides the previous field value to change listeners', () => { + const listener = vi.fn() + + const form = new InternalFormApi({ defaultValues: { x: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'x', + listeners: [{ triggers: ['change'], run: listener }], + }) + + field.handleChange('first') + field.handleChange('second') + + expect(listener).toHaveBeenCalledTimes(2) + expect(listener).toHaveBeenNthCalledWith(1, { + value: 'first', + prevValue: '', + fieldApi: field, + formApi: form, + }) + expect(listener).toHaveBeenNthCalledWith(2, { + value: 'second', + prevValue: 'first', + fieldApi: field, + formApi: form, + }) + }) + + it('does not provide a previous value for non-change field events', () => { + const listener = vi.fn() + + const form = new InternalFormApi({ defaultValues: { x: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'x', + listeners: [{ triggers: ['blur'], run: listener }], + }) + + field.handleBlur() + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith({ + value: '', + prevValue: undefined, + fieldApi: field, + formApi: form, + }) + }) + + it('gives an ancestor its own previous value when a descendant changes', () => { + const listener = vi.fn() + + const form = new InternalFormApi({ + defaultValues: { user: { name: '' } }, + }) + const parentField = form._getOrCreateFieldApi({ + name: 'user', + listeners: [{ triggers: ['change'], run: listener }], + }) + const childField = form._getOrCreateFieldApi({ name: 'user.name' }) + + childField.handleChange('Alice') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith({ + value: { name: 'Alice' }, + prevValue: { name: '' }, + fieldApi: parentField, + formApi: form, + }) + }) + it('blur listeners', () => { const listener = vi.fn() @@ -201,6 +271,7 @@ describe('field - listeners', () => { expect(listener).toHaveBeenCalledOnce() expect(listener).toHaveBeenCalledWith({ value: 'Alice', + prevValue: '', fieldApi: field, formApi: form, }) @@ -237,6 +308,7 @@ describe('field - listeners', () => { expect(watchedFieldListener).toHaveBeenCalledOnce() expect(watchedFieldListener).toHaveBeenCalledWith({ value: '', + prevValue: '', fieldApi: targetField, formApi: form, }) @@ -421,6 +493,7 @@ describe('field - listeners', () => { expect(listener).toHaveBeenCalledOnce() expect(listener).toHaveBeenCalledWith({ value: '', + prevValue: '', fieldApi: targetField, formApi: form, }) @@ -462,6 +535,7 @@ describe('field - listeners', () => { expect(listener).toHaveBeenCalledOnce() expect(listener).toHaveBeenCalledWith({ value: '', + prevValue: '', fieldApi: targetField, formApi: form, }) From a480960979ff080ed2c33ec1047c90fbe90ac53c Mon Sep 17 00:00:00 2001 From: Bar M <32390017+galshir@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:02:21 +0300 Subject: [PATCH 8/9] chore(form-core): add changeset for listener prevValue --- .changeset/brave-moons-listen.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/brave-moons-listen.md diff --git a/.changeset/brave-moons-listen.md b/.changeset/brave-moons-listen.md new file mode 100644 index 000000000..e5f3d4a40 --- /dev/null +++ b/.changeset/brave-moons-listen.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': minor +--- + +Add `prevValue` to form and field change listener contexts, exposing the value from immediately before the change that invoked the listener. From fc3181ccf0e39d544798ec6dd7a0301e00dc125d Mon Sep 17 00:00:00 2001 From: Bar M <32390017+galshir@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:58:33 +0300 Subject: [PATCH 9/9] test(form-core): expect prevValue in the multi-field linked listener spec The target field does not change, so its listener receives its own previous value, an empty string, on both calls. This was the one watched-field assertion the earlier commits did not update, and it failed pnpm test:lib. Co-Authored-By: Claude Opus 5.5 --- packages/form-core/tests/FieldApi/listeners.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/form-core/tests/FieldApi/listeners.spec.ts b/packages/form-core/tests/FieldApi/listeners.spec.ts index a6dafb476..34baf53c5 100644 --- a/packages/form-core/tests/FieldApi/listeners.spec.ts +++ b/packages/form-core/tests/FieldApi/listeners.spec.ts @@ -664,11 +664,13 @@ describe('field - listeners', () => { expect(listener).toHaveBeenCalledTimes(2) expect(listener).toHaveBeenNthCalledWith(1, { value: '', + prevValue: '', fieldApi: targetField, formApi: form, }) expect(listener).toHaveBeenNthCalledWith(2, { value: '', + prevValue: '', fieldApi: targetField, formApi: form, })