Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/brave-moons-listen.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 18 additions & 2 deletions packages/form-core/src/FieldApi/FieldApi.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -968,6 +968,7 @@ export class InternalFieldApi<
'fieldApiOverride' | '_skipFieldCreation'
>,
event: 'change' | 'blur' | 'submit',
prevFormValues?: TFormData,
): void {
if (this._isKilled) return

Expand Down Expand Up @@ -1013,7 +1014,12 @@ export class InternalFieldApi<
}))
}

currNode._notifyListener(event, seenListenerFields)
currNode._notifyListener(
event,
seenListenerFields,
null,
prevFormValues,
)

if (!doPropagate) return stop
return undefined
Expand All @@ -1029,6 +1035,7 @@ export class InternalFieldApi<
trigger: FieldListenerTriggers,
seenFields: WeakSet<AnyInternalFieldApi>,
onlyRunListenerInstances: ReadonlySet<InternalFieldListenerInstance> | null = null,
prevFormValues?: TFormData,
) {
if (this._isKilled) return

Expand All @@ -1050,6 +1057,10 @@ export class InternalFieldApi<
event: trigger,
fieldApi: this,
formApi: this.form,
prevValue:
prevFormValues === undefined
? undefined
: getBy(prevFormValues, this.name),
},
listenerInstancesToRun: onlyRunListenerInstances,
})
Expand All @@ -1064,7 +1075,12 @@ export class InternalFieldApi<
continue
}

watchingField._notifyListener(trigger, seenFields, listenerInstances)
watchingField._notifyListener(
trigger,
seenFields,
listenerInstances,
prevFormValues,
)
}

if (watchingFields.size === 0) {
Expand Down
10 changes: 7 additions & 3 deletions packages/form-core/src/FormApi/FormApi.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -524,7 +525,7 @@ export class InternalFormApi<
}))
}

this._notifyFieldChange(field, updateOptions)
this._notifyFieldChange(field, updateOptions, previousValues)
})

notifyDevtoolsFieldValueUpdate(field)
Expand Down Expand Up @@ -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')
Expand All @@ -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

Expand All @@ -690,6 +693,7 @@ export class InternalFormApi<
event: trigger,
formApi: this,
triggerFieldApi: triggerFieldApi ?? undefined,
prevValue,
},
})
}
Expand Down
2 changes: 1 addition & 1 deletion packages/form-core/src/FormApi/array-methods.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
6 changes: 6 additions & 0 deletions packages/form-core/src/listeners.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,16 @@ type FormInputContext = {
event: ListenerEvent
triggerFieldApi?: AnyInternalFieldApi
formApi: InternalFormApi<any, any, any>
/** The form values before the change that caused the event. */
prevValue?: unknown
}

type FieldInputContext = {
event: ListenerEvent
fieldApi: AnyInternalFieldApi
formApi: InternalFormApi<any, any, any>
/** The listening field's value before the change that caused the event. */
prevValue?: unknown
}

type InputContext = FormInputContext | FieldInputContext
Expand Down Expand Up @@ -192,6 +196,7 @@ export function runFormListenerPipeline({
formApi: ctx.formApi,
triggerFieldApi: ctx.triggerFieldApi,
value: ctx.formApi.state.values,
prevValue: ctx.prevValue,
}),
})
}
Expand Down Expand Up @@ -222,6 +227,7 @@ export function runFieldListenerPipeline({
value: ctx.fieldApi.value,
fieldApi: context.fieldApi,
formApi: ctx.formApi,
prevValue: ctx.prevValue,
}),
})
}
19 changes: 19 additions & 0 deletions packages/form-core/src/listeners.public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,15 @@ export interface FormListenerContext<
formApi: FormApi<TFormData, TFormErrorTypes>
/** 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
}

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

Expand Down Expand Up @@ -201,6 +271,7 @@ describe('field - listeners', () => {
expect(listener).toHaveBeenCalledOnce()
expect(listener).toHaveBeenCalledWith({
value: 'Alice',
prevValue: '',
fieldApi: field,
formApi: form,
})
Expand Down Expand Up @@ -237,6 +308,7 @@ describe('field - listeners', () => {
expect(watchedFieldListener).toHaveBeenCalledOnce()
expect(watchedFieldListener).toHaveBeenCalledWith({
value: '',
prevValue: '',
fieldApi: targetField,
formApi: form,
})
Expand Down Expand Up @@ -421,6 +493,7 @@ describe('field - listeners', () => {
expect(listener).toHaveBeenCalledOnce()
expect(listener).toHaveBeenCalledWith({
value: '',
prevValue: '',
fieldApi: targetField,
formApi: form,
})
Expand Down Expand Up @@ -462,6 +535,7 @@ describe('field - listeners', () => {
expect(listener).toHaveBeenCalledOnce()
expect(listener).toHaveBeenCalledWith({
value: '',
prevValue: '',
fieldApi: targetField,
formApi: form,
})
Expand Down Expand Up @@ -590,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,
})
Expand Down
Loading