Skip to content

Commit 6f76fee

Browse files
authored
fix: onSubmit should now behave as expected
* fix: memoize all non-function form props by default * chore: migrate useStableFormOpts to useMemo * fix: options passed to useField will not always cause a re-render * chore: minor code cleanups * test: add previously failing test to demonstrate fix * chore: fix linting * fix: running form.update repeatedly should not wipe form state * test: add test to validate formapi update behavior * test: add initial tests for useStableOptions * chore: remove useStableOpts and useFormCallback
1 parent 9424ed9 commit 6f76fee

File tree

11 files changed

+171
-60
lines changed

11 files changed

+171
-60
lines changed

docs/framework/react/quick-start.md

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,9 @@ import { useForm } from '@tanstack/react-form'
1313
export default function App() {
1414
const form = useForm({
1515
// Memoize your default values to prevent re-renders
16-
defaultValues: React.useMemo(
17-
() => ({
18-
fullName: '',
19-
}),
20-
[],
21-
),
16+
defaultValues: {
17+
fullName: '',
18+
},
2219
onSubmit: async (values) => {
2320
// Do something with form data
2421
console.log(values)

docs/overview.md

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,10 @@ function FieldInfo({ field }: { field: FieldApi<any, any> }) {
4444

4545
export default function App() {
4646
const form = useForm({
47-
// Memoize your default values to prevent re-renders
48-
defaultValues: React.useMemo(
49-
() => ({
50-
firstName: '',
51-
lastName: '',
52-
}),
53-
[],
54-
),
47+
defaultValues: {
48+
firstName: '',
49+
lastName: '',
50+
},
5551
onSubmit: async (values) => {
5652
// Do something with form data
5753
console.log(values)

examples/react/simple/src/index.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,10 @@ function FieldInfo({ field }: { field: FieldApi<any, any> }) {
1717
export default function App() {
1818
const form = useForm({
1919
// Memoize your default values to prevent re-renders
20-
defaultValues: React.useMemo(
21-
() => ({
22-
firstName: "",
23-
lastName: "",
24-
}),
25-
[],
26-
),
20+
defaultValues: {
21+
firstName: "",
22+
lastName: "",
23+
},
2724
onSubmit: async (values) => {
2825
// Do something with form data
2926
console.log(values);

packages/form-core/src/FormApi.ts

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -69,21 +69,20 @@ function getDefaultFormState<TData>(
6969
defaultState: Partial<FormState<TData>>,
7070
): FormState<TData> {
7171
return {
72-
values: {} as any,
73-
fieldMeta: {} as any,
74-
canSubmit: true,
75-
isFieldsValid: false,
76-
isFieldsValidating: false,
77-
isFormValid: false,
78-
isFormValidating: false,
79-
isSubmitted: false,
80-
isSubmitting: false,
81-
isTouched: false,
82-
isValid: false,
83-
isValidating: false,
84-
submissionAttempts: 0,
85-
formValidationCount: 0,
86-
...defaultState,
72+
values: defaultState.values ?? ({} as never),
73+
fieldMeta: defaultState.fieldMeta ?? ({} as never),
74+
canSubmit: defaultState.canSubmit ?? true,
75+
isFieldsValid: defaultState.isFieldsValid ?? false,
76+
isFieldsValidating: defaultState.isFieldsValidating ?? false,
77+
isFormValid: defaultState.isFormValid ?? false,
78+
isFormValidating: defaultState.isFormValidating ?? false,
79+
isSubmitted: defaultState.isSubmitted ?? false,
80+
isSubmitting: defaultState.isSubmitting ?? false,
81+
isTouched: defaultState.isTouched ?? false,
82+
isValid: defaultState.isValid ?? false,
83+
isValidating: defaultState.isValidating ?? false,
84+
submissionAttempts: defaultState.submissionAttempts ?? 0,
85+
formValidationCount: defaultState.formValidationCount ?? 0,
8786
}
8887
}
8988

@@ -156,15 +155,18 @@ export class FormApi<TFormData> {
156155
this.store.batch(() => {
157156
const shouldUpdateValues =
158157
options.defaultValues &&
159-
options.defaultValues !== this.options.defaultValues
158+
options.defaultValues !== this.options.defaultValues &&
159+
!this.state.isTouched
160160

161161
const shouldUpdateState =
162-
options.defaultState !== this.options.defaultState
162+
options.defaultState !== this.options.defaultState &&
163+
!this.state.isTouched
163164

164165
this.store.setState(() =>
165166
getDefaultFormState(
166167
Object.assign(
167168
{},
169+
this.state,
168170
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
169171
shouldUpdateState ? options.defaultState : {},
170172
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition

packages/form-core/src/tests/FormApi.spec.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,4 +213,58 @@ describe('form api', () => {
213213

214214
expect(form.getFieldValue('names')).toStrictEqual(['one', 'three', 'two'])
215215
})
216+
217+
it('should not wipe values when updating', () => {
218+
const form = new FormApi({
219+
defaultValues: {
220+
name: 'test',
221+
},
222+
})
223+
224+
form.setFieldValue('name', 'other')
225+
226+
expect(form.getFieldValue('name')).toEqual('other')
227+
228+
form.update()
229+
230+
expect(form.getFieldValue('name')).toEqual('other')
231+
})
232+
233+
it('should wipe default values when not touched', () => {
234+
const form = new FormApi({
235+
defaultValues: {
236+
name: 'test',
237+
},
238+
})
239+
240+
expect(form.getFieldValue('name')).toEqual('test')
241+
242+
form.update({
243+
defaultValues: {
244+
name: 'other',
245+
},
246+
})
247+
248+
expect(form.getFieldValue('name')).toEqual('other')
249+
})
250+
251+
it('should not wipe default values when touched', () => {
252+
const form = new FormApi({
253+
defaultValues: {
254+
name: 'one',
255+
},
256+
})
257+
258+
expect(form.getFieldValue('name')).toEqual('one')
259+
260+
form.setFieldValue('name', 'two', { touch: true })
261+
262+
form.update({
263+
defaultValues: {
264+
name: 'three',
265+
},
266+
})
267+
268+
expect(form.getFieldValue('name')).toEqual('two')
269+
})
216270
})

packages/react-form/src/tests/useForm.test.tsx

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
/// <reference lib="dom" />
2-
import { render } from '@testing-library/react'
2+
import { render, waitFor } from '@testing-library/react'
33
import userEvent from '@testing-library/user-event'
44
import '@testing-library/jest-dom'
55
import * as React from 'react'
6-
import { createFormFactory } from '..'
6+
import { createFormFactory, useForm } from '..'
77

88
const user = userEvent.setup()
99

@@ -78,4 +78,50 @@ describe('useForm', () => {
7878
expect(await findByText('FirstName')).toBeInTheDocument()
7979
expect(queryByText('LastName')).not.toBeInTheDocument()
8080
})
81+
82+
it('should handle submitting properly', async () => {
83+
function Comp() {
84+
const [submittedData, setSubmittedData] = React.useState<{
85+
firstName: string
86+
} | null>(null)
87+
88+
const form = useForm({
89+
defaultValues: {
90+
firstName: 'FirstName',
91+
},
92+
onSubmit: (data) => {
93+
setSubmittedData(data)
94+
},
95+
})
96+
97+
return (
98+
<form.Provider>
99+
<form.Field
100+
name="firstName"
101+
children={(field) => {
102+
return (
103+
<input
104+
value={field.state.value}
105+
onBlur={field.handleBlur}
106+
onChange={(e) => field.handleChange(e.target.value)}
107+
placeholder={'First name'}
108+
/>
109+
)
110+
}}
111+
/>
112+
<button onClick={form.handleSubmit}>Submit</button>
113+
{submittedData && <p>Submitted data: {submittedData.firstName}</p>}
114+
</form.Provider>
115+
)
116+
}
117+
118+
const { findByPlaceholderText, getByText } = render(<Comp />)
119+
const input = await findByPlaceholderText('First name')
120+
await user.clear(input)
121+
await user.type(input, 'OtherName')
122+
await user.click(getByText('Submit'))
123+
await waitFor(() =>
124+
expect(getByText('Submitted data: OtherName')).toBeInTheDocument(),
125+
)
126+
})
81127
})

packages/react-form/src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import type { FieldOptions } from '@tanstack/form-core'
2+
3+
export type UseFieldOptions<TData, TFormData> = FieldOptions<
4+
TData,
5+
TFormData
6+
> & {
7+
mode?: 'value' | 'array'
8+
}

packages/react-form/src/useField.tsx

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import * as React from 'react'
2-
//
1+
import React, { useState } from 'react'
32
import { useStore } from '@tanstack/react-store'
43
import type {
54
DeepKeys,
@@ -9,6 +8,8 @@ import type {
98
} from '@tanstack/form-core'
109
import { FieldApi, functionalUpdate } from '@tanstack/form-core'
1110
import { useFormContext, formContext } from './formContext'
11+
import { useIsomorphicLayoutEffect } from './utils/useIsomorphicLayoutEffect'
12+
import type { UseFieldOptions } from './types'
1213

1314
declare module '@tanstack/form-core' {
1415
// eslint-disable-next-line no-shadow
@@ -17,13 +18,6 @@ declare module '@tanstack/form-core' {
1718
}
1819
}
1920

20-
export type UseFieldOptions<TData, TFormData> = FieldOptions<
21-
TData,
22-
TFormData
23-
> & {
24-
mode?: 'value' | 'array'
25-
}
26-
2721
export type UseField<TFormData> = <TField extends DeepKeys<TFormData>>(
2822
opts?: { name: Narrow<TField> } & UseFieldOptions<
2923
DeepValue<TFormData, TField>,
@@ -37,7 +31,7 @@ export function useField<TData, TFormData>(
3731
// Get the form API either manually or from context
3832
const { formApi, parentFieldName } = useFormContext()
3933

40-
const [fieldApi] = React.useState<FieldApi<TData, TFormData>>(() => {
34+
const [fieldApi] = useState<FieldApi<TData, TFormData>>(() => {
4135
const name = (
4236
typeof opts.index === 'number'
4337
? [parentFieldName, opts.index, opts.name]
@@ -53,8 +47,13 @@ export function useField<TData, TFormData>(
5347
return api
5448
})
5549

56-
// Keep options up to date as they are rendered
57-
fieldApi.update({ ...opts, form: formApi } as never)
50+
/**
51+
* fieldApi.update should not have any side effects. Think of it like a `useRef`
52+
* that we need to keep updated every render with the most up-to-date information.
53+
*/
54+
useIsomorphicLayoutEffect(() => {
55+
fieldApi.update({ ...opts, form: formApi } as never)
56+
})
5857

5958
useStore(
6059
fieldApi.store as any,
@@ -66,7 +65,7 @@ export function useField<TData, TFormData>(
6665
)
6766

6867
// Instantiates field meta and removes it when unrendered
69-
React.useEffect(() => fieldApi.mount(), [fieldApi])
68+
useIsomorphicLayoutEffect(() => fieldApi.mount(), [fieldApi])
7069

7170
return fieldApi
7271
}

packages/react-form/src/useForm.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import type { FormState, FormOptions } from '@tanstack/form-core'
22
import { FormApi, functionalUpdate } from '@tanstack/form-core'
33
import type { NoInfer } from '@tanstack/react-store'
44
import { useStore } from '@tanstack/react-store'
5-
import React from 'react'
5+
import React, { type ReactNode, useState } from 'react'
66
import { type UseField, type FieldComponent, Field, useField } from './useField'
77
import { formContext } from './formContext'
8+
import { useIsomorphicLayoutEffect } from './utils/useIsomorphicLayoutEffect'
89

910
declare module '@tanstack/form-core' {
1011
// eslint-disable-next-line no-shadow
@@ -17,15 +18,13 @@ declare module '@tanstack/form-core' {
1718
) => TSelected
1819
Subscribe: <TSelected = NoInfer<FormState<TFormData>>>(props: {
1920
selector?: (state: NoInfer<FormState<TFormData>>) => TSelected
20-
children:
21-
| ((state: NoInfer<TSelected>) => React.ReactNode)
22-
| React.ReactNode
21+
children: ((state: NoInfer<TSelected>) => ReactNode) | ReactNode
2322
}) => any
2423
}
2524
}
2625

2726
export function useForm<TData>(opts?: FormOptions<TData>): FormApi<TData> {
28-
const [formApi] = React.useState(() => {
27+
const [formApi] = useState(() => {
2928
// @ts-ignore
3029
const api = new FormApi<TData>(opts)
3130

@@ -58,9 +57,13 @@ export function useForm<TData>(opts?: FormOptions<TData>): FormApi<TData> {
5857

5958
formApi.useStore((state) => state.isSubmitting)
6059

61-
React.useEffect(() => {
60+
/**
61+
* formApi.update should not have any side effects. Think of it like a `useRef`
62+
* that we need to keep updated every render with the most up-to-date information.
63+
*/
64+
useIsomorphicLayoutEffect(() => {
6265
formApi.update(opts)
63-
}, [formApi, opts])
66+
})
6467

6568
return formApi as any
6669
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/* c8 ignore start */
2+
export const isBrowser = typeof window !== 'undefined'
3+
/* c8 ignore end */

0 commit comments

Comments
 (0)