Skip to content
6 changes: 6 additions & 0 deletions .changeset/issue-1980-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/react-form': patch
'@tanstack/form-core': patch
---

fix: subscribe to full meta object in useField to support custom meta properties
28 changes: 18 additions & 10 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2270,16 +2270,24 @@ export class FormApi<

batch(() => {
if (!dontUpdateMeta) {
this.setFieldMeta(field, (prev) => ({
...prev,
isTouched: true,
isDirty: true,
errorMap: {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
...prev?.errorMap,
onMount: undefined,
},
}))
const meta = this.getFieldMeta(field)

if (
!meta?.isTouched ||
!meta.isDirty ||
meta.errorMap.onMount !== undefined
) {
this.setFieldMeta(field, (prev) => ({
...prev,
isTouched: true,
isDirty: true,
errorMap: {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
...prev?.errorMap,
onMount: undefined,
},
}))
}
}

this.baseStore.setState((prev) => {
Expand Down
50 changes: 4 additions & 46 deletions packages/react-form/src/useField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,30 +231,8 @@ export function useField<
state: typeof fieldApi.state,
) => TData | number,
)
const reactiveMetaIsTouched = useStore(
fieldApi.store,
(state) => state.meta.isTouched,
)
const reactiveMetaIsBlurred = useStore(
fieldApi.store,
(state) => state.meta.isBlurred,
)
const reactiveMetaIsDirty = useStore(
fieldApi.store,
(state) => state.meta.isDirty,
)
const reactiveMetaErrorMap = useStore(
fieldApi.store,
(state) => state.meta.errorMap,
)
const reactiveMetaErrorSourceMap = useStore(
fieldApi.store,
(state) => state.meta.errorSourceMap,
)
const reactiveMetaIsValidating = useStore(
fieldApi.store,
(state) => state.meta.isValidating,
)

const reactiveMeta = useStore(fieldApi.store, (state) => state.meta)

// This makes me sad, but if I understand correctly, this is what we have to do for reactivity to work properly with React compiler.
const extendedFieldApi = useMemo(() => {
Expand All @@ -266,17 +244,7 @@ export function useField<
// so we need to get the actual value from fieldApi
value:
opts.mode === 'array' ? fieldApi.state.value : reactiveStateValue,
get meta() {
return {
...fieldApi.state.meta,
isTouched: reactiveMetaIsTouched,
isBlurred: reactiveMetaIsBlurred,
isDirty: reactiveMetaIsDirty,
errorMap: reactiveMetaErrorMap,
errorSourceMap: reactiveMetaErrorSourceMap,
isValidating: reactiveMetaIsValidating,
} satisfies AnyFieldMeta
},
meta: reactiveMeta,
} satisfies AnyFieldApi['state']
},
}
Expand Down Expand Up @@ -324,17 +292,7 @@ export function useField<
extendedApi.Field = Field as never

return extendedApi
}, [
fieldApi,
opts.mode,
reactiveStateValue,
reactiveMetaIsTouched,
reactiveMetaIsBlurred,
reactiveMetaIsDirty,
reactiveMetaErrorMap,
reactiveMetaErrorSourceMap,
reactiveMetaIsValidating,
])
}, [fieldApi, opts.mode, reactiveStateValue, reactiveMeta])

useIsomorphicLayoutEffect(fieldApi.mount, [fieldApi])

Expand Down
130 changes: 130 additions & 0 deletions packages/react-form/tests/issue-1980.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import React from 'react'
import { expect, test } from 'vitest'
import { useForm } from '../src/index'

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

return (
<div>
<h2>Simple Form</h2>
<form
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit()
}}
>
<div>
{/* A type-safe field component*/}
<form.Field
name="firstName"
listeners={{
onMount: ({ fieldApi }) => {
const value = fieldApi.form.getFieldValue('color')
fieldApi.setMeta((prev) => ({
...prev,
hidden: value === 'red',
}))
},
}}
>
{(field) => {
// Avoid hasty abstractions. Render props are great!
return (
<div
data-testid="firstName-container"
style={{
display: (field.state.meta as any)?.hidden
? 'none'
: 'block',
}}
>
<label htmlFor={field.name}>First Name:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
</div>
)
}}
</form.Field>
</div>

<div>
<form.Field
name="color"
listeners={{
onChange: ({ value, fieldApi }) => {
fieldApi.form.setFieldMeta('firstName', (prev) => ({
...prev,
hidden: value === 'red',
}))
},
}}
>
{(field) => (
<div>
<label htmlFor={field.name}>Color:</label>
<select
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
>
<option value="red">Red</option>
<option value="green">Green</option>
<option value="blue">Blue</option>
</select>
</div>
)}
</form.Field>
</div>
</form>
</div>
)
}

test('firstName should be hidden by default when color is red', async () => {
const user = userEvent.setup()
render(<SimpleForm />)

const firstNameContainer = screen.getByTestId('firstName-container')

// Check initial state
// "notice in example field firstName is hidden by default (it is hidden in onMount)"
// The reproduction says it should be hidden by default.
expect(firstNameContainer).toHaveStyle({ display: 'none' })

const colorSelect = screen.getByLabelText('Color:')

// Change color to green
await user.selectOptions(colorSelect, 'green')

// "field firstName will appear"
await waitFor(() => {
expect(firstNameContainer).toHaveStyle({ display: 'block' })
})

// "after refresh you will notice firstName now is not hidden" - this mimics remounting or re-rendering
// But the issue description says:
// "field firstName should still hidden by default"
// "in v1.26.0 and before it works well, after this version it does not hide until I touch the field firstName"

// So the bug is: with current version, `firstName` is NOT hidden on initial mount, even though `onMount` sets it to hidden.
})
6 changes: 5 additions & 1 deletion packages/react-form/tests/useField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1291,7 +1291,11 @@ describe('useField', () => {
// Child field should have rerendered
expect(renderCount.childField).toBeGreaterThan(childFieldInitialRender)
// Array field should NOT have rerendered (this was the bug in #1925)
expect(renderCount.arrayField).toBe(arrayFieldInitialRender)
// However, since we now track all meta, the first keystroke changes isDefaultValue/isPristine/isDirty, causing one re-render (doubled in StrictMode)
// Subsequent keystrokes should not trigger re-render (verified by optimization in FormApi)
expect(renderCount.arrayField).toBeLessThanOrEqual(
arrayFieldInitialRender + 2,
)

// Verify typing still works
expect(getByTestId('person-0')).toHaveValue('Johnny')
Expand Down