Skip to content

fix(form-core): preserve reset(newValues) when update() is called with stale options#2218

Open
LeSingh1 wants to merge 1 commit into
TanStack:mainfrom
LeSingh1:fix/reset-new-default-values-ignored-during-onSubmit
Open

fix(form-core): preserve reset(newValues) when update() is called with stale options#2218
LeSingh1 wants to merge 1 commit into
TanStack:mainfrom
LeSingh1:fix/reset-new-default-values-ignored-during-onSubmit

Conversation

@LeSingh1

@LeSingh1 LeSingh1 commented Jun 20, 2026

Copy link
Copy Markdown

Summary

Fixes #1681form.reset(newValues) called inside onSubmit is ignored and the form reverts to its original default values.

Root cause

reset(values) (with explicit new values) does two things:

  1. Updates this.options.defaultValues = values in-place
  2. Calls this.baseStore.setState(...) with the new values

After the onSubmit promise resolves, React re-renders (because isSubmitting flipped back to false). The framework then calls formApi.update(opts) inside useIsomorphicLayoutEffect — where opts is the original options object captured in the render closure, still holding the pre-reset defaultValues.

Inside update():

  • oldOptions = this.options → has the new defaultValues (set by reset)
  • this.options = options → overwrites back to original defaultValues
  • options.defaultValues !== oldOptions.defaultValuestrue
  • !this.state.isTouchedtrue (reset cleared touched state)
  • shouldUpdateValues = true → form values reset to original defaults ❌

Fix

Add a private _defaultValuesOverridden flag on FormApi. When reset(values) is called with explicit values (and !keepDefaultValues), set the flag to true. In update(), if the flag is set, skip the shouldUpdateValues branch and preserve the defaultValues that reset() stored on this.options. The flag is cleared after one update() cycle so subsequent prop-driven defaultValues changes continue to work correctly.

Test plan

  • Added a new unit test in packages/form-core/tests/FormApi.spec.ts that directly simulates the race condition: calls reset(newValues) then update(originalOpts) and asserts that values are not reverted
  • Existing test form should reset default value when resetting in onSubmit (both core and react-form suites) continues to pass
  • All 499 @tanstack/form-core tests pass
  • All 123 @tanstack/react-form tests pass

Summary by CodeRabbit

  • Bug Fixes
    • Fixed a race condition where resetting a form with new values could have those values unexpectedly reverted to stale defaults.

…h stale options

When reset(values) is called inside onSubmit, the new values were being
overwritten on the next render cycle because useIsomorphicLayoutEffect
calls formApi.update(opts) with the original render-closure options.
update() compared options.defaultValues (original) to this.options.defaultValues
(set by reset), found a difference, and reset the form state back to the
original defaults.

Fix: add a private _defaultValuesOverridden flag that is set by reset()
whenever new explicit values are provided. update() now checks this flag
and, when set, skips the shouldUpdateValues logic and preserves the
defaultValues that reset() installed. The flag is cleared after one
update() cycle so subsequent prop-driven defaultValues changes still work.

Fixes TanStack#1681
@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

FormApi gains a private _defaultValuesOverridden boolean flag. When reset(values) is called with explicit values (and keepDefaultValues is not set), the flag is set to true. On the next update() call, the flag is detected, cleared, and the stale incoming options.defaultValues is overwritten with the preserved post-reset defaults before returning early. A regression test covering the submit-then-reset-then-update race condition is added.

Changes

Reset/update race condition fix

Layer / File(s) Summary
_defaultValuesOverridden flag, update() guard, and reset() setter
packages/form-core/src/FormApi.ts, packages/form-core/tests/FormApi.spec.ts
Declares the private _defaultValuesOverridden boolean (default false). reset() sets it to true when new explicit values are applied. update() checks the flag, clears it, restores the preserved defaultValues from oldOptions, and returns early — skipping the stale-options comparison. The regression test submits a form, resets it with { name: 'test' }, calls update() with the original stale options, and asserts that neither form.state.values nor form.options.defaultValues reverts.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

  • TanStack/form#2190: Also modifies FormApi.update() in packages/form-core/src/FormApi.ts, specifically the defaultValues comparison and state update pathway, which overlaps directly with the guard added here.

Suggested reviewers

  • crutchcorn

Poem

🐇 A reset was called with new values in tow,
But update() swooped in with old defaults — oh no!
A flag called _defaultValuesOverridden stood tall,
"Not today, stale closure! You shall not install!"
The rabbit patched the race, and all was well at last. 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main fix: preventing reset() values from being overwritten by stale options in update().
Description check ✅ Passed The description thoroughly explains the root cause, the implemented fix, and includes comprehensive test coverage. All required sections are present and well-documented.
Linked Issues check ✅ Passed The PR implementation directly addresses issue #1681 by fixing the race condition where reset(newValues) called in onSubmit was ignored due to stale options overwriting the new values.
Out of Scope Changes check ✅ Passed All changes are focused on fixing the reset/update race condition. No unrelated modifications or scope creep detected in the form-core files.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/form-core/tests/FormApi.spec.ts (1)

147-173: ⚡ Quick win

Add one assertion path for defaultState changes during the override cycle.

Given the new one-cycle guard in update(), it’s worth adding a companion case where stale defaultValues are ignored but a real defaultState delta still applies. That locks in the intended contract and prevents regressions around this race fix.

🤖 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/tests/FormApi.spec.ts` around lines 147 - 173, Add an
assertion path to the test that verifies legitimate defaultState changes are
still applied during the update cycle. After the final update(originalOptions)
call, add a scenario where you update the form with new defaultState values and
assert that those changes are properly applied to form.state, ensuring that the
guard preventing stale defaultValues from overwriting reset values does not
inadvertently block valid defaultState updates. This locks in the contract that
the one-cycle guard in the update() method only blocks stale defaultValues while
still allowing real defaultState deltas to apply.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@packages/form-core/src/FormApi.ts`:
- Around line 1760-1773: The early return in the `_defaultValuesOverridden`
block within the `update()` method prevents `shouldUpdateState` from being
evaluated, which causes changes to `options.defaultState` to be dropped in that
cycle. Instead of returning early after handling the defaultValues override,
continue processing the update by allowing the `shouldUpdateState` evaluation to
proceed. Merge the override logic so that the old defaultValues are preserved
while still permitting synchronization of defaultState changes in the same
cycle.

---

Nitpick comments:
In `@packages/form-core/tests/FormApi.spec.ts`:
- Around line 147-173: Add an assertion path to the test that verifies
legitimate defaultState changes are still applied during the update cycle. After
the final update(originalOptions) call, add a scenario where you update the form
with new defaultState values and assert that those changes are properly applied
to form.state, ensuring that the guard preventing stale defaultValues from
overwriting reset values does not inadvertently block valid defaultState
updates. This locks in the contract that the one-cycle guard in the update()
method only blocks stale defaultValues while still allowing real defaultState
deltas to apply.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 56b6b81d-b98f-4195-bf44-b4c411ddb596

📥 Commits

Reviewing files that changed from the base of the PR and between 6a73479 and 39fb30b.

📒 Files selected for processing (2)
  • packages/form-core/src/FormApi.ts
  • packages/form-core/tests/FormApi.spec.ts

Comment on lines +1760 to +1773
// If reset(newValues) was called, the incoming `options` still carries the
// original (pre-reset) defaultValues from the render closure. We must not
// let those stale values overwrite what reset() set, so we skip the
// shouldUpdateValues branch and clear the flag for the next render.
if (this._defaultValuesOverridden) {
this._defaultValuesOverridden = false
// Keep the defaultValues that reset() stored on this.options so that
// future renders compare against them correctly.
this.options = {
...options,
defaultValues: oldOptions.defaultValues,
}
return
}

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

Line 1764: early return unintentionally suppresses defaultState synchronization.

When _defaultValuesOverridden is set, update() returns before evaluating shouldUpdateState, so a real options.defaultState change in that cycle can be silently dropped.

💡 Suggested fix
-    if (this._defaultValuesOverridden) {
-      this._defaultValuesOverridden = false
-      // Keep the defaultValues that reset() stored on this.options so that
-      // future renders compare against them correctly.
-      this.options = {
-        ...options,
-        defaultValues: oldOptions.defaultValues,
-      }
-      return
-    }
+    let nextOptions = options
+    if (this._defaultValuesOverridden) {
+      this._defaultValuesOverridden = false
+      // Keep defaultValues written by reset(), but still allow normal update()
+      // processing (eg defaultState sync) in this cycle.
+      nextOptions = {
+        ...options,
+        defaultValues: oldOptions.defaultValues,
+      }
+      this.options = nextOptions
+    }

-    const shouldUpdateValues =
-      options.defaultValues &&
-      !evaluate(options.defaultValues, oldOptions.defaultValues) &&
+    const shouldUpdateValues =
+      nextOptions.defaultValues &&
+      !evaluate(nextOptions.defaultValues, oldOptions.defaultValues) &&
       !this.state.isTouched

     const shouldUpdateState =
-      !evaluate(options.defaultState, oldOptions.defaultState) &&
+      !evaluate(nextOptions.defaultState, oldOptions.defaultState) &&
       !this.state.isTouched

@@
-            shouldUpdateState ? options.defaultState : {},
+            shouldUpdateState ? nextOptions.defaultState : {},

             shouldUpdateValues
               ? {
-                  values: options.defaultValues,
+                  values: nextOptions.defaultValues,
                 }
               : {},
🤖 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 1760 - 1773, The early return
in the `_defaultValuesOverridden` block within the `update()` method prevents
`shouldUpdateState` from being evaluated, which causes changes to
`options.defaultState` to be dropped in that cycle. Instead of returning early
after handling the defaultValues override, continue processing the update by
allowing the `shouldUpdateState` evaluation to proceed. Merge the override logic
so that the old defaultValues are preserved while still permitting
synchronization of defaultState changes in the same cycle.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

form.reset during onSubmit ignores new default values

1 participant