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() + }) })