From 9da6cd475d39573b3c54265fc47878d70221ced3 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sun, 26 Oct 2025 20:37:46 +0100 Subject: [PATCH 1/9] add useTypedAppFormContext --- packages/react-form/src/createFormHook.tsx | 110 +++++++++++++----- .../react-form/tests/createFormHook.test.tsx | 64 ++++++++-- 2 files changed, 137 insertions(+), 37 deletions(-) diff --git a/packages/react-form/src/createFormHook.tsx b/packages/react-form/src/createFormHook.tsx index 33c229c6b..71e2d43e1 100644 --- a/packages/react-form/src/createFormHook.tsx +++ b/packages/react-form/src/createFormHook.tsx @@ -65,6 +65,33 @@ type UnwrapDefaultOrAny = [DefaultT] extends [T] : T : T +function useFormContext() { + const form = useContext(formContext) + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!form) { + throw new Error( + '`formContext` only works when within a `formComponent` passed to `createFormHook`', + ) + } + + return form as ReactFormExtendedApi< + // If you need access to the form data, you need to use `withForm` instead + Record, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + > +} + export function createFormHookContexts() { function useFieldContext() { const field = useContext(fieldContext) @@ -103,33 +130,6 @@ export function createFormHookContexts() { > } - function useFormContext() { - const form = useContext(formContext) - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!form) { - throw new Error( - '`formContext` only works when within a `formComponent` passed to `createFormHook`', - ) - } - - return form as ReactFormExtendedApi< - // If you need access to the form data, you need to use `withForm` instead - Record, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any - > - } - return { fieldContext, useFieldContext, useFormContext, formContext } } @@ -540,9 +540,65 @@ export function createFormHook< } } + /** + * ⚠️ **Use withForm whenever possible.** + * + * Gets a typed form from the `` context. + */ + function useTypedAppFormContext< + TFormData, + TOnMount extends undefined | FormValidateOrFn, + TOnChange extends undefined | FormValidateOrFn, + TOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TOnBlur extends undefined | FormValidateOrFn, + TOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TOnSubmit extends undefined | FormValidateOrFn, + TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TOnDynamic extends undefined | FormValidateOrFn, + TOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TOnServer extends undefined | FormAsyncValidateOrFn, + TSubmitMeta, + >( + // @ts-expect-error Unused parameter + props: FormOptions< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TOnServer, + TSubmitMeta + >, + ): AppFieldExtendedReactFormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TOnServer, + TSubmitMeta, + TComponents, + TFormComponents + > { + const form = useFormContext() + + return form as never + } + return { useAppForm, withForm, withFieldGroup, + useTypedAppFormContext, } } diff --git a/packages/react-form/tests/createFormHook.test.tsx b/packages/react-form/tests/createFormHook.test.tsx index 7f57c3e2b..3cbc9be64 100644 --- a/packages/react-form/tests/createFormHook.test.tsx +++ b/packages/react-form/tests/createFormHook.test.tsx @@ -31,16 +31,17 @@ function SubscribeButton({ label }: { label: string }) { ) } -const { useAppForm, withForm, withFieldGroup } = createFormHook({ - fieldComponents: { - TextField, - }, - formComponents: { - SubscribeButton, - }, - fieldContext, - formContext, -}) +const { useAppForm, withForm, withFieldGroup, useTypedAppFormContext } = + createFormHook({ + fieldComponents: { + TextField, + }, + formComponents: { + SubscribeButton, + }, + fieldContext, + formContext, + }) describe('createFormHook', () => { it('should allow to set default value', () => { @@ -580,4 +581,47 @@ describe('createFormHook', () => { await user.click(target) expect(result).toHaveTextContent('1') }) + + it('should allow using typed app form', () => { + type Person = { + firstName: string + lastName: string + } + const formOpts = formOptions({ + defaultValues: { + firstName: 'FirstName', + lastName: 'LastName', + } as Person, + }) + + function Child() { + const form = useTypedAppFormContext(formOpts) + + return ( + } + /> + ) + } + + function Parent() { + const form = useAppForm({ + defaultValues: { + firstName: 'FirstName', + lastName: 'LastName', + } as Person, + }) + + return ( + + + + ) + } + + const { getByLabelText } = render() + const input = getByLabelText('Testing') + expect(input).toHaveValue('FirstName') + }) }) From 6dba476b6f763ae624049524afc4bdaebb112195 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sun, 26 Oct 2025 20:43:00 +0100 Subject: [PATCH 2/9] remove unused ts-expect-error --- packages/react-form/src/createFormHook.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react-form/src/createFormHook.tsx b/packages/react-form/src/createFormHook.tsx index 71e2d43e1..b9b7d4869 100644 --- a/packages/react-form/src/createFormHook.tsx +++ b/packages/react-form/src/createFormHook.tsx @@ -559,7 +559,6 @@ export function createFormHook< TOnServer extends undefined | FormAsyncValidateOrFn, TSubmitMeta, >( - // @ts-expect-error Unused parameter props: FormOptions< TFormData, TOnMount, From d2d7c3485d1777d6dca9f42239e770aeff68103d Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sun, 26 Oct 2025 20:40:17 +0100 Subject: [PATCH 3/9] Add great-ghosts-sin.md --- .changeset/great-ghosts-sin.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/great-ghosts-sin.md diff --git a/.changeset/great-ghosts-sin.md b/.changeset/great-ghosts-sin.md new file mode 100644 index 000000000..36afe64d2 --- /dev/null +++ b/.changeset/great-ghosts-sin.md @@ -0,0 +1,5 @@ +--- +"@tanstack/react-form": patch +--- + +- add `useTypedAppFormContext` From 9684f3f77ca1e4515b5e6880499d9799b81148ff Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 26 Oct 2025 19:41:14 +0000 Subject: [PATCH 4/9] ci: apply automated fixes and generate docs --- .changeset/great-ghosts-sin.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/great-ghosts-sin.md b/.changeset/great-ghosts-sin.md index 36afe64d2..ad153f41d 100644 --- a/.changeset/great-ghosts-sin.md +++ b/.changeset/great-ghosts-sin.md @@ -1,5 +1,5 @@ --- -"@tanstack/react-form": patch +'@tanstack/react-form': patch --- - add `useTypedAppFormContext` From 53f46ec905e3e5f3f5c53b818e947715e9667e60 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sun, 26 Oct 2025 20:46:55 +0100 Subject: [PATCH 5/9] chore: fix PR --- packages/react-form/src/createFormHook.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-form/src/createFormHook.tsx b/packages/react-form/src/createFormHook.tsx index b9b7d4869..27cde30f9 100644 --- a/packages/react-form/src/createFormHook.tsx +++ b/packages/react-form/src/createFormHook.tsx @@ -559,7 +559,7 @@ export function createFormHook< TOnServer extends undefined | FormAsyncValidateOrFn, TSubmitMeta, >( - props: FormOptions< + _props: FormOptions< TFormData, TOnMount, TOnChange, From 021a61be58255a16cfc983f84b6c4e258139a1c4 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Mon, 19 Jan 2026 17:28:48 +0100 Subject: [PATCH 6/9] chore: add unit tests --- .../react-form/tests/createFormHook.test.tsx | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/packages/react-form/tests/createFormHook.test.tsx b/packages/react-form/tests/createFormHook.test.tsx index 3cbc9be64..57df138e5 100644 --- a/packages/react-form/tests/createFormHook.test.tsx +++ b/packages/react-form/tests/createFormHook.test.tsx @@ -624,4 +624,79 @@ describe('createFormHook', () => { const input = getByLabelText('Testing') expect(input).toHaveValue('FirstName') }) + + it('should throw if `useTypedAppFormContext` is used without AppForm', () => { + type Person = { + firstName: string + lastName: string + } + const formOpts = formOptions({ + defaultValues: { + firstName: 'FirstName', + lastName: 'LastName', + } as Person, + }) + + function Child() { + const form = useTypedAppFormContext(formOpts) + + return ( + } + /> + ) + } + + function Parent() { + const form = useAppForm({ + defaultValues: { + firstName: 'FirstName', + lastName: 'LastName', + } as Person, + }) + + return + } + + expect(() => render()).toThrow() + }) + + it('should allow using typed app form with form components', () => { + type Person = { + firstName: string + lastName: string + } + const formOpts = formOptions({ + defaultValues: { + firstName: 'FirstName', + lastName: 'LastName', + } as Person, + }) + + function Child() { + const form = useTypedAppFormContext(formOpts) + + return + } + + function Parent() { + const form = useAppForm({ + defaultValues: { + firstName: 'FirstName', + lastName: 'LastName', + } as Person, + }) + + return ( + + + + ) + } + + const { getByText } = render() + const button = getByText('Testing') + expect(button).toBeInTheDocument() + }) }) From 5a55abdad2815824f0aca64f52d2bdaadbbe75a7 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Mon, 19 Jan 2026 17:45:33 +0100 Subject: [PATCH 7/9] docs(react-form): amend form composition with context --- .../react/guides/form-composition.md | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/docs/framework/react/guides/form-composition.md b/docs/framework/react/guides/form-composition.md index e334a82b4..597947934 100644 --- a/docs/framework/react/guides/form-composition.md +++ b/docs/framework/react/guides/form-composition.md @@ -248,6 +248,61 @@ const ChildForm = withForm({ }) ``` +### Context as a last resort + +There are cases where passing `form` with `withForm` is not feasible. You may encounter it with components that don't +allow you to change their props. + +For example, consider the following TanStack Router usage: + +```ts +function RouteComponent() { + const form = useAppForm({...formOptions, /* ... */ }) + // cannot be customized or receive additional props + return +} +``` + +In edge cases like this, a context-based fallback is available to access the form instance. + +```ts +const { useAppForm, useTypedAppFormContext } = createFormHook({ + fieldContext, + formContext, + fieldComponents: {}, + formComponents: {}, +}) +``` + +> [!IMPORTANT] Type safety +> This mechanism exists solely to bridge integration constraints and should be avoided whenever `withForm` is possible. +> Context will not warn you when the types do not align. You risk runtime errors with this implementation. + +Usage: + +```tsx +// sharedOpts.ts +const formOpts = formOptions({ + /* ... */ +}) + +function ParentComponent() { + const form = useAppForm({ ...formOptions /* ... */ }) + + return ( + + + + ) +} + +function ChildComponent() { + const form = useTypedAppFormContext({ ...formOptions }) + + // You now have access to form components, field components and fields +} +``` + ## Reusing groups of fields in multiple forms Sometimes, a pair of fields are so closely related that it makes sense to group and reuse them — like the password example listed in the [linked fields guide](./linked-fields.md). Instead of repeating this logic across multiple forms, you can utilize the `withFieldGroup` higher-order component. From 754b76bf1eab8c0c3349ae65725fc7312bcd5ce5 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 21 Jan 2026 07:48:48 +0100 Subject: [PATCH 8/9] chore: fix changeset --- .changeset/{great-ghosts-sin.md => dirty-pillows-lay.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .changeset/{great-ghosts-sin.md => dirty-pillows-lay.md} (55%) diff --git a/.changeset/great-ghosts-sin.md b/.changeset/dirty-pillows-lay.md similarity index 55% rename from .changeset/great-ghosts-sin.md rename to .changeset/dirty-pillows-lay.md index ad153f41d..a081dfb3e 100644 --- a/.changeset/great-ghosts-sin.md +++ b/.changeset/dirty-pillows-lay.md @@ -2,4 +2,4 @@ '@tanstack/react-form': patch --- -- add `useTypedAppFormContext` +add `useTypedAppFormContext` From c32a21f596924d193af11ceb0abb6c3b36b5b975 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 21 Jan 2026 07:56:22 +0100 Subject: [PATCH 9/9] chore: fix wrong version bump --- .changeset/{dirty-pillows-lay.md => ninety-crews-smash.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .changeset/{dirty-pillows-lay.md => ninety-crews-smash.md} (55%) diff --git a/.changeset/dirty-pillows-lay.md b/.changeset/ninety-crews-smash.md similarity index 55% rename from .changeset/dirty-pillows-lay.md rename to .changeset/ninety-crews-smash.md index a081dfb3e..5e97dcd30 100644 --- a/.changeset/dirty-pillows-lay.md +++ b/.changeset/ninety-crews-smash.md @@ -1,5 +1,5 @@ --- -'@tanstack/react-form': patch +'@tanstack/react-form': minor --- add `useTypedAppFormContext`