diff --git a/.changeset/ninety-crews-smash.md b/.changeset/ninety-crews-smash.md
new file mode 100644
index 000000000..5e97dcd30
--- /dev/null
+++ b/.changeset/ninety-crews-smash.md
@@ -0,0 +1,5 @@
+---
+'@tanstack/react-form': minor
+---
+
+add `useTypedAppFormContext`
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.
diff --git a/packages/react-form/src/createFormHook.tsx b/packages/react-form/src/createFormHook.tsx
index 33c229c6b..27cde30f9 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,64 @@ 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,
+ >(
+ _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..57df138e5 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,122 @@ 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')
+ })
+
+ 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()
+ })
})