Skip to content
Draft
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/delay-onmount.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/form-core': minor
---

delay onMount listeners and validators until defaultValues are provided (not undefined or null)
53 changes: 43 additions & 10 deletions docs/framework/react/guides/async-initial-values.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,36 +16,69 @@ As such, this guide shows you how you can mix-n-match TanStack Form with TanStac

## Basic Usage

TanStack Form automatically handles async initial values by delaying `onMount` listeners and validation until `defaultValues` are provided (i.e. not `undefined` or `null`).

```tsx
import { useForm } from '@tanstack/react-form'
import { useQuery } from '@tanstack/react-query'

export default function App() {
const {data, isLoading} = useQuery({
const { data } = useQuery({
queryKey: ['data'],
queryFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000))
return {firstName: 'FirstName', lastName: "LastName"}
}
return { firstName: 'FirstName', lastName: 'LastName' }
},
})

const form = useForm({
defaultValues: {
firstName: data?.firstName ?? '',
lastName: data?.lastName ?? '',
},
defaultValues: data,
onSubmit: async ({ value }) => {
// Do something with form data
console.log(value)
},
})

if (isLoading) return <p>Loading..</p>
// You can show a loading spinner while waiting for data
// The form's onMount validation/listeners will NOT run until data is available
if (!data) {
return <p>Loading...</p>
}

return (
// ...
<form.Provider>
<form
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit()
}}
>
<form.Field
name="firstName"
children={(field) => (
<input
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
/>
<form.Field
name="lastName"
children={(field) => (
<input
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
/>
<button type="submit">Submit</button>
</form>
</form.Provider>
)
}
```

This will show a loading spinner until the data is fetched, and then it will render the form with the fetched data as the initial values.
In the example above, even though `useForm` is initialized immediately, the form validation and `onMount` effects will effectively "pause" until `data` is populated. This allows you to comfortably render a loading state without triggering premature validation errors or side effects.
92 changes: 58 additions & 34 deletions packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1127,6 +1127,16 @@ export class FieldApi<
formListeners: Record<ListenerCause, ReturnType<typeof setTimeout> | null>
}

/**
* @private
*/
_hasMounted = false

/**
* @private
*/
_isMounted = false

/**
* Initializes a new `FieldApi` instance.
*/
Expand Down Expand Up @@ -1247,6 +1257,8 @@ export class FieldApi<
mount = () => {
const cleanup = this.store.mount()

this._isMounted = true

if (this.options.defaultValue !== undefined && !this.getMeta().isTouched) {
this.form.setFieldValue(this.name, this.options.defaultValue, {
dontUpdateMeta: true,
Expand All @@ -1258,41 +1270,10 @@ export class FieldApi<

this.update(this.options as never)

const { onMount } = this.options.validators || {}

if (onMount) {
const error = this.runValidator({
validate: onMount,
value: {
value: this.state.value,
fieldApi: this,
validationSource: 'field',
},
type: 'validate',
})
if (error) {
this.setMeta(
(prev) =>
({
...prev,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
errorMap: { ...prev?.errorMap, onMount: error },
errorSourceMap: {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
...prev?.errorSourceMap,
onMount: 'field',
},
}) as never,
)
}
return () => {
this._isMounted = false
cleanup()
}

this.options.listeners?.onMount?.({
value: this.state.value,
fieldApi: this,
})

return cleanup
}

/**
Expand Down Expand Up @@ -1343,6 +1324,49 @@ export class FieldApi<
if (!this.form.getFieldMeta(this.name)) {
this.form.setFieldMeta(this.name, this.state.meta)
}

if (
this._isMounted &&
!this._hasMounted &&
this.form.options.defaultValues !== undefined &&
this.form.options.defaultValues !== null
) {
this._hasMounted = true

const { onMount } = this.options.validators || {}

if (onMount) {
const error = this.runValidator({
validate: onMount,
value: {
value: this.state.value,
fieldApi: this,
validationSource: 'field',
},
type: 'validate',
})
if (error) {
this.setMeta(
(prev) =>
({
...prev,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
errorMap: { ...prev?.errorMap, onMount: error },
errorSourceMap: {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
...prev?.errorSourceMap,
onMount: 'field',
},
}) as never,
)
}
}

this.options.listeners?.onMount?.({
value: this.state.value,
fieldApi: this,
})
}
}

/**
Expand Down
44 changes: 41 additions & 3 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,16 @@ export class FormApi<
*/
private _devtoolsSubmissionOverride: boolean

/**
* @private
*/
_hasMounted = false

/**
* @private
*/
_isMounted = false

/**
* Constructs a new `FormApi` instance with the given form options.
*/
Expand Down Expand Up @@ -1396,11 +1406,11 @@ export class FormApi<
formEventClient.emit('form-unmounted', {
id: this._formId,
})
}

this.options.listeners?.onMount?.({ formApi: this })
this._isMounted = false
}

const { onMount } = this.options.validators || {}
this._isMounted = true

// broadcast form state for devtools on mounting
formEventClient.emit('form-api', {
Expand All @@ -1409,6 +1419,18 @@ export class FormApi<
options: this.options,
})

if (
this.options.defaultValues === undefined ||
this.options.defaultValues === null
) {
return cleanup
}

this.options.listeners?.onMount?.({ formApi: this })
this._hasMounted = true

const { onMount } = this.options.validators || {}

// if no validation skip
if (!onMount) return cleanup

Expand Down Expand Up @@ -1443,6 +1465,22 @@ export class FormApi<
// Options need to be updated first so that when the store is updated, the state is correct for the derived state
this.options = options

if (
this._isMounted &&
!this._hasMounted &&
options.defaultValues !== undefined &&
options.defaultValues !== null
) {
this.options.listeners?.onMount?.({ formApi: this })
this._hasMounted = true

const { onMount } = this.options.validators || {}

if (onMount) {
this.validateSync('mount')
}
}

// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const shouldUpdateReeval = !!options.transform?.deps?.some(
(val, i) => val !== this.prevTransformArray[i],
Expand Down
Loading
Loading