From e8c1c033fb1d59ea57119f5c250370522e979319 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Mon, 1 Dec 2025 04:27:25 -0800 Subject: [PATCH 1/6] fix: edgecases with React compiler now work better --- packages/react-form/src/useField.tsx | 81 ++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 9 deletions(-) diff --git a/packages/react-form/src/useField.tsx b/packages/react-form/src/useField.tsx index ced50e2a7..8e2030e00 100644 --- a/packages/react-form/src/useField.tsx +++ b/packages/react-form/src/useField.tsx @@ -2,7 +2,7 @@ import { useMemo, useRef } from 'react' import { useStore } from '@tanstack/react-store' -import { FieldApi, functionalUpdate } from '@tanstack/form-core' +import { AnyFieldMeta, FieldApi, functionalUpdate } from '@tanstack/form-core' import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' import type { DeepKeys, @@ -205,7 +205,74 @@ export function useField< name: opts.name, }) - const extendedApi: typeof api & + return api + // We only want to + // update on name changes since those are at risk of becoming stale. The field + // state must be up to date for the internal JSX render. + // The other options can freely be in `fieldApi.update` + }, [opts.form, opts.name]) + + const reactiveStateValue = useStore(fieldApi.store, (state) => state.value) + 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, + ) + + // 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 reactiveFieldApi = useMemo( + () => ({ + ...fieldApi, + get state() { + return { + value: reactiveStateValue, + get meta() { + return { + ...fieldApi.state.meta, + isTouched: reactiveMetaIsTouched, + isBlurred: reactiveMetaIsBlurred, + isDirty: reactiveMetaIsDirty, + errorMap: reactiveMetaErrorMap, + errorSourceMap: reactiveMetaErrorSourceMap, + isValidating: reactiveMetaIsValidating, + } satisfies AnyFieldMeta + }, + } satisfies (typeof fieldApi)['state'] + }, + }), + [ + fieldApi, + reactiveStateValue, + reactiveMetaIsTouched, + reactiveMetaIsBlurred, + reactiveMetaIsDirty, + reactiveMetaErrorMap, + reactiveMetaErrorSourceMap, + reactiveMetaIsValidating, + ], + ) + + const extendedFieldApi = useMemo(() => { + const extendedApi: typeof reactiveFieldApi & ReactFieldApi< TParentData, TFormOnMount, @@ -219,16 +286,12 @@ export function useField< TFormOnDynamicAsync, TFormOnServer, TPatentSubmitMeta - > = api as never + > = reactiveFieldApi as never extendedApi.Field = Field as never return extendedApi - // We only want to - // update on name changes since those are at risk of becoming stale. The field - // state must be up to date for the internal JSX render. - // The other options can freely be in `fieldApi.update` - }, [opts.form, opts.name]) + }, [reactiveFieldApi]) useIsomorphicLayoutEffect(fieldApi.mount, [fieldApi]) @@ -252,7 +315,7 @@ export function useField< : undefined, ) - return fieldApi + return extendedFieldApi } /** From 585c4d3568841b7d0a44099605fb7df49c3512b5 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Mon, 1 Dec 2025 04:33:29 -0800 Subject: [PATCH 2/6] chore: fix type annotations --- packages/react-form/src/useField.tsx | 54 ++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/packages/react-form/src/useField.tsx b/packages/react-form/src/useField.tsx index 8e2030e00..eb0758c10 100644 --- a/packages/react-form/src/useField.tsx +++ b/packages/react-form/src/useField.tsx @@ -2,9 +2,11 @@ import { useMemo, useRef } from 'react' import { useStore } from '@tanstack/react-store' -import { AnyFieldMeta, FieldApi, functionalUpdate } from '@tanstack/form-core' +import { FieldApi, functionalUpdate } from '@tanstack/form-core' import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' import type { + AnyFieldApi, + AnyFieldMeta, DeepKeys, DeepValue, FieldAsyncValidateOrFn, @@ -256,7 +258,7 @@ export function useField< isValidating: reactiveMetaIsValidating, } satisfies AnyFieldMeta }, - } satisfies (typeof fieldApi)['state'] + } satisfies AnyFieldApi['state'] }, }), [ @@ -315,7 +317,45 @@ export function useField< : undefined, ) - return extendedFieldApi + return extendedFieldApi as FieldApi< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TPatentSubmitMeta + > & + ReactFieldApi< + TParentData, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TPatentSubmitMeta + > } /** @@ -718,13 +758,7 @@ export const Field = (< const jsxToDisplay = useMemo( () => functionalUpdate(children, fieldApi as any), - /** - * The reason this exists is to fix an issue with the React Compiler. - * Namely, functionalUpdate is memoized where it checks for `fieldApi`, which is a static type. - * This means that when `state.value` changes, it does not trigger a re-render. The useMemo explicitly fixes this problem - */ - // eslint-disable-next-line react-hooks/exhaustive-deps - [children, fieldApi, fieldApi.state.value, fieldApi.state.meta], + [children, fieldApi], ) return (<>{jsxToDisplay}) as never }) satisfies FunctionComponent< From c06bba497fc8a8536e132a11a09b32d21c56f469 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Mon, 1 Dec 2025 08:30:02 -0800 Subject: [PATCH 3/6] chore: WIP address Dom's feedback --- packages/react-form/src/useField.tsx | 130 +++++++++++++-------------- 1 file changed, 60 insertions(+), 70 deletions(-) diff --git a/packages/react-form/src/useField.tsx b/packages/react-form/src/useField.tsx index eb0758c10..1a76801d9 100644 --- a/packages/react-form/src/useField.tsx +++ b/packages/react-form/src/useField.tsx @@ -1,6 +1,6 @@ 'use client' -import { useMemo, useRef } from 'react' +import { useMemo, useRef, useState } from 'react' import { useStore } from '@tanstack/react-store' import { FieldApi, functionalUpdate } from '@tanstack/form-core' import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' @@ -197,22 +197,29 @@ export function useField< ) { // Keep a snapshot of options so that React Compiler doesn't // wrongly optimize fieldApi. - const optsRef = useRef(opts) - optsRef.current = opts + const [prevOptions, setPrevOptions] = useState(() => ({ + form: opts.form, + name: opts.name, + })) - const fieldApi = useMemo(() => { - const api = new FieldApi({ - ...optsRef.current, - form: opts.form, - name: opts.name, + const [fieldApi, setFieldApi] = useState(() => { + return new FieldApi({ + ...opts, }) + }) - return api - // We only want to - // update on name changes since those are at risk of becoming stale. The field - // state must be up to date for the internal JSX render. - // The other options can freely be in `fieldApi.update` - }, [opts.form, opts.name]) + // We only want to + // update on name changes since those are at risk of becoming stale. The field + // state must be up to date for the internal JSX render. + // The other options can freely be in `fieldApi.update` + if (prevOptions.form !== opts.form || prevOptions.name !== opts.name) { + setFieldApi( + new FieldApi({ + ...opts, + }), + ) + setPrevOptions({ form: opts.form, name: opts.name }) + } const reactiveStateValue = useStore(fieldApi.store, (state) => state.value) const reactiveMetaIsTouched = useStore( @@ -241,8 +248,8 @@ export function useField< ) // 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 reactiveFieldApi = useMemo( - () => ({ + const extendedFieldApi = useMemo(() => { + const reactiveFieldApi = { ...fieldApi, get state() { return { @@ -260,21 +267,33 @@ export function useField< }, } satisfies AnyFieldApi['state'] }, - }), - [ - fieldApi, - reactiveStateValue, - reactiveMetaIsTouched, - reactiveMetaIsBlurred, - reactiveMetaIsDirty, - reactiveMetaErrorMap, - reactiveMetaErrorSourceMap, - reactiveMetaIsValidating, - ], - ) + } - const extendedFieldApi = useMemo(() => { - const extendedApi: typeof reactiveFieldApi & + const extendedApi: FieldApi< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TPatentSubmitMeta + > & ReactFieldApi< TParentData, TFormOnMount, @@ -293,7 +312,16 @@ export function useField< extendedApi.Field = Field as never return extendedApi - }, [reactiveFieldApi]) + }, [ + fieldApi, + reactiveStateValue, + reactiveMetaIsTouched, + reactiveMetaIsBlurred, + reactiveMetaIsDirty, + reactiveMetaErrorMap, + reactiveMetaErrorSourceMap, + reactiveMetaIsValidating, + ]) useIsomorphicLayoutEffect(fieldApi.mount, [fieldApi]) @@ -317,45 +345,7 @@ export function useField< : undefined, ) - return extendedFieldApi as FieldApi< - TParentData, - TName, - TData, - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync, - TOnDynamic, - TOnDynamicAsync, - TFormOnMount, - TFormOnChange, - TFormOnChangeAsync, - TFormOnBlur, - TFormOnBlurAsync, - TFormOnSubmit, - TFormOnSubmitAsync, - TFormOnDynamic, - TFormOnDynamicAsync, - TFormOnServer, - TPatentSubmitMeta - > & - ReactFieldApi< - TParentData, - TFormOnMount, - TFormOnChange, - TFormOnChangeAsync, - TFormOnBlur, - TFormOnBlurAsync, - TFormOnSubmit, - TFormOnSubmitAsync, - TFormOnDynamic, - TFormOnDynamicAsync, - TFormOnServer, - TPatentSubmitMeta - > + return extendedFieldApi } /** From b7f3a2c55068d37ee4da2eee3211b4593522bf70 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Mon, 1 Dec 2025 08:37:17 -0800 Subject: [PATCH 4/6] chore: fix broken test --- packages/react-form/tests/useForm.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react-form/tests/useForm.test.tsx b/packages/react-form/tests/useForm.test.tsx index 4f4aa707a..dbb7ff845 100644 --- a/packages/react-form/tests/useForm.test.tsx +++ b/packages/react-form/tests/useForm.test.tsx @@ -909,8 +909,9 @@ describe('useForm', () => { <> {(arrayField) => - arrayField.state.value.map((row, i) => ( - + arrayField.state.value.map((_, i) => ( + // eslint-disable-next-line @eslint-react/no-array-index-key + {(field) => { expect(field.name).toBe(`foo[${i}].name`) expect(field.state.value).not.toBeUndefined() From 1ddf938f8ad1b078f58b59e6c64d06170fda5764 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Mon, 1 Dec 2025 09:06:06 -0800 Subject: [PATCH 5/6] chore: fix issues with Form --- packages/form-core/src/FormApi.ts | 2 +- packages/react-form/src/useForm.tsx | 49 +++++++++++++++++------------ 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index b4887b97d..e7e0dd08b 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -979,7 +979,7 @@ export class FormApi< /** * @private */ - private _formId: string + _formId: string /** * @private */ diff --git a/packages/react-form/src/useForm.tsx b/packages/react-form/src/useForm.tsx index 3246b41a4..59b2c5402 100644 --- a/packages/react-form/src/useForm.tsx +++ b/packages/react-form/src/useForm.tsx @@ -2,7 +2,7 @@ import { FormApi, functionalUpdate, uuid } from '@tanstack/form-core' import { useStore } from '@tanstack/react-store' -import { useRef, useState } from 'react' +import { useMemo, useState } from 'react' import { Field } from './useField' import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' import type { @@ -13,12 +13,7 @@ import type { FormState, FormValidateOrFn, } from '@tanstack/form-core' -import type { - FunctionComponent, - PropsWithChildren, - ReactElement, - ReactNode, -} from 'react' +import type { FunctionComponent, PropsWithChildren, ReactNode } from 'react' import type { FieldComponent } from './useField' import type { NoInfer } from '@tanstack/react-store' @@ -189,14 +184,11 @@ export function useForm< TSubmitMeta >, ) { - const formId = useRef(opts?.formId as never) - - if (!formId.current) { - formId.current = uuid() - } + const fallbackFormId = useState(() => uuid())[0] + const [prevFormId, setPrevFormId] = useState(opts?.formId as never) - const [formApi] = useState(() => { - const api = new FormApi< + const [formApi, setFormApi] = useState(() => { + return new FormApi< TFormData, TOnMount, TOnChange, @@ -209,8 +201,16 @@ export function useForm< TOnDynamicAsync, TOnServer, TSubmitMeta - >({ ...opts, formId: formId.current }) + >({ ...opts, formId: opts?.formId ?? fallbackFormId }) + }) + if (prevFormId !== opts?.formId) { + const formId = opts?.formId ?? fallbackFormId + setFormApi(new FormApi({ ...opts, formId })) + setPrevFormId(formId) + } + + const extendedFormApi = useMemo(() => { const extendedApi: ReactFormExtendedApi< TFormData, TOnMount, @@ -224,16 +224,25 @@ export function useForm< TOnDynamicAsync, TOnServer, TSubmitMeta - > = api as never + > = { + ...formApi, + // We must add all `get`ters from `core`'s `FormApi` here, as otherwise the spread operator won't catch those + get formId(): string { + return formApi._formId + }, + get state() { + return formApi.store.state + }, + } as never extendedApi.Field = function APIField(props) { - return + return } extendedApi.Subscribe = function Subscribe(props: any) { return ( @@ -241,7 +250,7 @@ export function useForm< } return extendedApi - }) + }, [formApi]) useIsomorphicLayoutEffect(formApi.mount, []) @@ -253,5 +262,5 @@ export function useForm< formApi.update(opts) }) - return formApi + return extendedFormApi } From a3da6efc69ab53a9145a1cdade88ec77d18fe5a8 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Mon, 1 Dec 2025 10:35:21 -0800 Subject: [PATCH 6/6] chore: add changeset --- .changeset/social-candies-count.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/social-candies-count.md diff --git a/.changeset/social-candies-count.md b/.changeset/social-candies-count.md new file mode 100644 index 000000000..3acc41a82 --- /dev/null +++ b/.changeset/social-candies-count.md @@ -0,0 +1,6 @@ +--- +'@tanstack/react-form': patch +'@tanstack/form-core': patch +--- + +Fixed issues with React Compiler