Skip to content
Merged
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.
Comment on lines +2 to +5
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Recheck semver level for this behavioral precedence change.

This changes user-visible semantics of isDefaultValue, reset(), and resetField(); consumers depending on previous dual-default behavior may break. Please confirm whether this should be a major bump (or feature-flagged), not minor.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/gentle-jars-share.md around lines 2 - 5, The changeset claims a
minor bump but introduces behavioral precedence altering user-visible semantics
(Prioritized Default System) affecting isDefaultValue, form.reset(), and
form.resetField(); re-evaluate and change the changeset to a major version bump
or gate the behavior behind a feature flag/opt-in, update the .changeset text to
reflect the decision, and ensure release notes explicitly document the
behavioral change and migration guidance for consumers relying on previous
dual-default behavior.

61 changes: 41 additions & 20 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1132,16 +1132,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,
)
)
Comment on lines +1135 to +1140
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

null field defaults lose precedence due to ??.

Using nullish coalescing here makes explicit defaultValue: null fall back to form defaults, which breaks Field-over-Form priority and can desynchronize reset/default metadata behavior.

Suggested fix
-const isDefaultValue = evaluate(
-  curFieldVal,
-  this.getFieldInfo(fieldName)?.instance?.options.defaultValue ??
-    getBy(this.options.defaultValues, fieldName),
-)
+const fieldDefaultValue =
+  this.getFieldInfo(fieldName).instance?.options.defaultValue
+const prioritizedDefaultValue =
+  fieldDefaultValue !== undefined
+    ? fieldDefaultValue
+    : getBy(this.options.defaultValues, fieldName)
+const isDefaultValue = evaluate(curFieldVal, prioritizedDefaultValue)
-const fieldDefault =
-  this.getFieldInfo(field).instance?.options.defaultValue
-const formDefault = getBy(this.options.defaultValues, field)
-const targetValue = fieldDefault ?? formDefault
+const fieldDefault =
+  this.getFieldInfo(field).instance?.options.defaultValue
+const formDefault = getBy(this.options.defaultValues, field)
+const targetValue = fieldDefault !== undefined ? fieldDefault : formDefault

Also applies to: 2601-2605

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/form-core/src/FormApi.ts` around lines 1135 - 1140, The code uses
nullish coalescing (??) so an explicit field default of null is treated as
missing and falls back to form defaults; update the logic in the evaluate call
to treat only undefined as "missing" (preserve null/false/0 as valid defaults).
Concretely, retrieve the field-specific default via
this.getFieldInfo(fieldName)?.instance?.options.defaultValue into a local (e.g.,
fieldDefault) and use fieldDefault !== undefined ? fieldDefault :
getBy(this.options.defaultValues, fieldName) when calling evaluate (change the
occurrence inside evaluate and the similar occurrences around the 2601-2605
area) so explicit null values retain precedence.


if (
prevFieldInfo &&
Expand Down Expand Up @@ -1535,16 +1531,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 @@ -2583,15 +2598,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
Copy Markdown
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
Copy Markdown
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 @@ -174,6 +174,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