Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/gentle-jars-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/form-core': minor
---

Introduced a **Prioritized Default System** that ensures consistency between field metadata and form reset behavior. This change prioritizes field-level default values over form-level defaults across `isDefaultValue` derivation, `form.reset()`, and `form.resetField()`. This ensures that field metadata accurately reflects the state the form would return to upon reset and prevents `undefined` from being incorrectly treated as a default when a value is explicitly specified.
61 changes: 41 additions & 20 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1091,16 +1091,12 @@ export class FormApi<
// As primitives, we don't need to aggressively persist the same referential value for performance reasons
const isFieldValid = !isNonEmptyArray(fieldErrors)
const isFieldPristine = !currBaseMeta.isDirty
const isDefaultValue =
evaluate(
curFieldVal,
const isDefaultValue = evaluate(
curFieldVal,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
this.getFieldInfo(fieldName)?.instance?.options.defaultValue ??
getBy(this.options.defaultValues, fieldName),
) ||
evaluate(
curFieldVal,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
this.getFieldInfo(fieldName)?.instance?.options.defaultValue,
)
)

if (
prevFieldInfo &&
Expand Down Expand Up @@ -1507,16 +1503,35 @@ export class FormApi<
}
}

this.baseStore.setState(() =>
getDefaultFormState({
this.baseStore.setState(() => {
let nextValues =
values ??
this.options.defaultValues ??
this.options.defaultState?.values

if (!values) {
;(Object.values(this.fieldInfo) as FieldInfo<any>[]).forEach(
(fieldInfo) => {
if (
fieldInfo.instance &&
fieldInfo.instance.options.defaultValue !== undefined
) {
nextValues = setBy(
nextValues,
fieldInfo.instance.name,
fieldInfo.instance.options.defaultValue,
)
}
},
)
}

return getDefaultFormState({
...(this.options.defaultState as any),
values:
values ??
this.options.defaultValues ??
this.options.defaultState?.values,
values: nextValues,
fieldMetaBase,
}),
)
})
})
}

/**
Expand Down Expand Up @@ -2542,15 +2557,21 @@ export class FormApi<
*/
resetField = <TField extends DeepKeys<TFormData>>(field: TField) => {
this.baseStore.setState((prev) => {
const fieldDefault =
this.getFieldInfo(field).instance?.options.defaultValue
const formDefault = getBy(this.options.defaultValues, field)
const targetValue = fieldDefault ?? formDefault

return {
...prev,
fieldMetaBase: {
...prev.fieldMetaBase,
[field]: defaultFieldMeta,
},
values: this.options.defaultValues
? setBy(prev.values, field, getBy(this.options.defaultValues, field))
: prev.values,
values:
targetValue !== undefined
? setBy(prev.values, field, targetValue)
: prev.values,
}
})
}
Expand Down
50 changes: 49 additions & 1 deletion packages/form-core/tests/FieldApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ describe('field api', () => {
expect(field.getMeta().isDefaultValue).toBe(false)

field.setValue('test')
expect(field.getMeta().isDefaultValue).toBe(true)
expect(field.getMeta().isDefaultValue).toBe(false)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reminder to self: Check git blame.

I don't this change is good, but I want to know the context of why it was explicitly listed as unit test.

Copy link
Contributor Author

@Kyujenius Kyujenius Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@LeCarbonator Thanks for taking the time to review this!

I checked the git blame - this test was intentionally written this way in PR #1456 It wasn't a mistake, so I want to be careful here.

But I think there's a philosophical question: What should "default" mean when form-level and field-level disagree?

The Two Interpretations

Original (OR logic): "Both are defaults"

isDefaultValue = matches(formDefault) || matches(fieldDefault)

Proposed (?? logic): "The one reset() uses is THE default"

isDefaultValue = matches(fieldDefault ?? formDefault)

Why I Lean Toward the Proposed Logic

In #1081, explained the purpose of isDefaultValue:

"if you do something like !meta.isDefaultValue will give you the react ecosystem's isDirty"

RHF's isDirty is true when currentValue !== defaultValue. A form has exactly one clean state.

With the original OR logic:

  • isDefaultValue is true for both 'test' and 'another-test'
  • So !isDefaultValue is false for both values
  • This means the form has two clean states

That feels inconsistent with RHF's model. A form should have one default state, not two.

The Practical Problem

// User checks before reset
if (!field.getMeta().isDefaultValue) {
  form.reset()  // "I'm not at default, let me reset"
}

With OR logic, if value is 'test' (form-level default):

  • isDefaultValuetrue → user skips reset
  • But reset() would actually change the value to 'another-test'

The user gets misleading information.

I Could Be Wrong

I understand the original design might have had reasons I'm not aware of. Maybe there are use cases where treating both as "default" makes sense. What's your take on this?


form.resetField('name')
expect(field.getMeta().isDefaultValue).toBe(true)
Expand All @@ -130,6 +130,54 @@ describe('field api', () => {
expect(field.getMeta().isDefaultValue).toBe(true)
})

it('should be false when value is undefined and a default value is specified in form-level only', () => {
const form = new FormApi({
defaultValues: {
name: 'foo',
},
})
form.mount()

const field = new FieldApi({
form,
name: 'name',
})
field.mount()

expect(field.getMeta().isDefaultValue).toBe(true)

// Set to undefined - should be false because 'foo' is the default
field.setValue(undefined as any)
expect(field.getMeta().isDefaultValue).toBe(false)
})

it('should handle falsy values correctly in isDefaultValue', () => {
const form = new FormApi({
defaultValues: {
count: 0,
active: false,
text: '',
},
})
form.mount()

const countField = new FieldApi({ form, name: 'count' })
const activeField = new FieldApi({ form, name: 'active' })
const textField = new FieldApi({ form, name: 'text' })
countField.mount()
activeField.mount()
textField.mount()

expect(countField.getMeta().isDefaultValue).toBe(true)
expect(activeField.getMeta().isDefaultValue).toBe(true)
expect(textField.getMeta().isDefaultValue).toBe(true)

countField.setValue(1)
expect(countField.getMeta().isDefaultValue).toBe(false)
countField.setValue(0)
expect(countField.getMeta().isDefaultValue).toBe(true)
})

it('should update the fields meta isDefaultValue with arrays - simple', () => {
const form = new FormApi({
defaultValues: {
Expand Down
40 changes: 40 additions & 0 deletions packages/form-core/tests/FormApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,46 @@ describe('form api', () => {
expect(form.state.values).toEqual({ name: 'initial' })
})

it('should prioritize field-level defaultValue over form-level defaultValues on reset', () => {
const form = new FormApi({
defaultValues: {
name: 'form-default',
age: 25,
},
})
form.mount()

const nameField = new FieldApi({
form,
name: 'name',
defaultValue: 'field-default',
})
nameField.mount()

const ageField = new FieldApi({
form,
name: 'age',
})
ageField.mount()

// Change values
nameField.setValue('changed-name')
ageField.setValue(30)

expect(form.state.values).toEqual({
name: 'changed-name',
age: 30,
})

// Reset without arguments - field-level defaultValue should take priority
form.reset()

expect(form.state.values).toEqual({
name: 'field-default', // field's defaultValue, not form's
age: 25, // form's defaultValues (no field-level default)
})
})

it('should handle multiple fields with mixed mount states', () => {
const form = new FormApi({
defaultValues: {
Expand Down
Loading