Skip to content

Commit a7eda7a

Browse files
feat(react-form): add useTypedAppFormContext (#1826)
* add useTypedAppFormContext * remove unused ts-expect-error * Add great-ghosts-sin.md * ci: apply automated fixes and generate docs * chore: fix PR * chore: add unit tests * docs(react-form): amend form composition with context * chore: fix changeset * chore: fix wrong version bump --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 41faffe commit a7eda7a

File tree

4 files changed

+271
-37
lines changed

4 files changed

+271
-37
lines changed

.changeset/ninety-crews-smash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/react-form': minor
3+
---
4+
5+
add `useTypedAppFormContext`

docs/framework/react/guides/form-composition.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,61 @@ const ChildForm = withForm({
248248
})
249249
```
250250

251+
### Context as a last resort
252+
253+
There are cases where passing `form` with `withForm` is not feasible. You may encounter it with components that don't
254+
allow you to change their props.
255+
256+
For example, consider the following TanStack Router usage:
257+
258+
```ts
259+
function RouteComponent() {
260+
const form = useAppForm({...formOptions, /* ... */ })
261+
// <Outlet /> cannot be customized or receive additional props
262+
return <Outlet />
263+
}
264+
```
265+
266+
In edge cases like this, a context-based fallback is available to access the form instance.
267+
268+
```ts
269+
const { useAppForm, useTypedAppFormContext } = createFormHook({
270+
fieldContext,
271+
formContext,
272+
fieldComponents: {},
273+
formComponents: {},
274+
})
275+
```
276+
277+
> [!IMPORTANT] Type safety
278+
> This mechanism exists solely to bridge integration constraints and should be avoided whenever `withForm` is possible.
279+
> Context will not warn you when the types do not align. You risk runtime errors with this implementation.
280+
281+
Usage:
282+
283+
```tsx
284+
// sharedOpts.ts
285+
const formOpts = formOptions({
286+
/* ... */
287+
})
288+
289+
function ParentComponent() {
290+
const form = useAppForm({ ...formOptions /* ... */ })
291+
292+
return (
293+
<form.AppForm>
294+
<ChildComponent />
295+
</form.AppForm>
296+
)
297+
}
298+
299+
function ChildComponent() {
300+
const form = useTypedAppFormContext({ ...formOptions })
301+
302+
// You now have access to form components, field components and fields
303+
}
304+
```
305+
251306
## Reusing groups of fields in multiple forms
252307
253308
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.

packages/react-form/src/createFormHook.tsx

Lines changed: 82 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,33 @@ type UnwrapDefaultOrAny<DefaultT, T> = [DefaultT] extends [T]
6565
: T
6666
: T
6767

68+
function useFormContext() {
69+
const form = useContext(formContext)
70+
71+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
72+
if (!form) {
73+
throw new Error(
74+
'`formContext` only works when within a `formComponent` passed to `createFormHook`',
75+
)
76+
}
77+
78+
return form as ReactFormExtendedApi<
79+
// If you need access to the form data, you need to use `withForm` instead
80+
Record<string, never>,
81+
any,
82+
any,
83+
any,
84+
any,
85+
any,
86+
any,
87+
any,
88+
any,
89+
any,
90+
any,
91+
any
92+
>
93+
}
94+
6895
export function createFormHookContexts() {
6996
function useFieldContext<TData>() {
7097
const field = useContext(fieldContext)
@@ -103,33 +130,6 @@ export function createFormHookContexts() {
103130
>
104131
}
105132

106-
function useFormContext() {
107-
const form = useContext(formContext)
108-
109-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
110-
if (!form) {
111-
throw new Error(
112-
'`formContext` only works when within a `formComponent` passed to `createFormHook`',
113-
)
114-
}
115-
116-
return form as ReactFormExtendedApi<
117-
// If you need access to the form data, you need to use `withForm` instead
118-
Record<string, never>,
119-
any,
120-
any,
121-
any,
122-
any,
123-
any,
124-
any,
125-
any,
126-
any,
127-
any,
128-
any,
129-
any
130-
>
131-
}
132-
133133
return { fieldContext, useFieldContext, useFormContext, formContext }
134134
}
135135

@@ -540,9 +540,64 @@ export function createFormHook<
540540
}
541541
}
542542

543+
/**
544+
* ⚠️ **Use withForm whenever possible.**
545+
*
546+
* Gets a typed form from the `<form.AppForm />` context.
547+
*/
548+
function useTypedAppFormContext<
549+
TFormData,
550+
TOnMount extends undefined | FormValidateOrFn<TFormData>,
551+
TOnChange extends undefined | FormValidateOrFn<TFormData>,
552+
TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
553+
TOnBlur extends undefined | FormValidateOrFn<TFormData>,
554+
TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
555+
TOnSubmit extends undefined | FormValidateOrFn<TFormData>,
556+
TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
557+
TOnDynamic extends undefined | FormValidateOrFn<TFormData>,
558+
TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
559+
TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>,
560+
TSubmitMeta,
561+
>(
562+
_props: FormOptions<
563+
TFormData,
564+
TOnMount,
565+
TOnChange,
566+
TOnChangeAsync,
567+
TOnBlur,
568+
TOnBlurAsync,
569+
TOnSubmit,
570+
TOnSubmitAsync,
571+
TOnDynamic,
572+
TOnDynamicAsync,
573+
TOnServer,
574+
TSubmitMeta
575+
>,
576+
): AppFieldExtendedReactFormApi<
577+
TFormData,
578+
TOnMount,
579+
TOnChange,
580+
TOnChangeAsync,
581+
TOnBlur,
582+
TOnBlurAsync,
583+
TOnSubmit,
584+
TOnSubmitAsync,
585+
TOnDynamic,
586+
TOnDynamicAsync,
587+
TOnServer,
588+
TSubmitMeta,
589+
TComponents,
590+
TFormComponents
591+
> {
592+
const form = useFormContext()
593+
594+
return form as never
595+
}
596+
543597
return {
544598
useAppForm,
545599
withForm,
546600
withFieldGroup,
601+
useTypedAppFormContext,
547602
}
548603
}

packages/react-form/tests/createFormHook.test.tsx

Lines changed: 129 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,17 @@ function SubscribeButton({ label }: { label: string }) {
3131
)
3232
}
3333

34-
const { useAppForm, withForm, withFieldGroup } = createFormHook({
35-
fieldComponents: {
36-
TextField,
37-
},
38-
formComponents: {
39-
SubscribeButton,
40-
},
41-
fieldContext,
42-
formContext,
43-
})
34+
const { useAppForm, withForm, withFieldGroup, useTypedAppFormContext } =
35+
createFormHook({
36+
fieldComponents: {
37+
TextField,
38+
},
39+
formComponents: {
40+
SubscribeButton,
41+
},
42+
fieldContext,
43+
formContext,
44+
})
4445

4546
describe('createFormHook', () => {
4647
it('should allow to set default value', () => {
@@ -580,4 +581,122 @@ describe('createFormHook', () => {
580581
await user.click(target)
581582
expect(result).toHaveTextContent('1')
582583
})
584+
585+
it('should allow using typed app form', () => {
586+
type Person = {
587+
firstName: string
588+
lastName: string
589+
}
590+
const formOpts = formOptions({
591+
defaultValues: {
592+
firstName: 'FirstName',
593+
lastName: 'LastName',
594+
} as Person,
595+
})
596+
597+
function Child() {
598+
const form = useTypedAppFormContext(formOpts)
599+
600+
return (
601+
<form.AppField
602+
name="firstName"
603+
children={(field) => <field.TextField label="Testing" />}
604+
/>
605+
)
606+
}
607+
608+
function Parent() {
609+
const form = useAppForm({
610+
defaultValues: {
611+
firstName: 'FirstName',
612+
lastName: 'LastName',
613+
} as Person,
614+
})
615+
616+
return (
617+
<form.AppForm>
618+
<Child />
619+
</form.AppForm>
620+
)
621+
}
622+
623+
const { getByLabelText } = render(<Parent />)
624+
const input = getByLabelText('Testing')
625+
expect(input).toHaveValue('FirstName')
626+
})
627+
628+
it('should throw if `useTypedAppFormContext` is used without AppForm', () => {
629+
type Person = {
630+
firstName: string
631+
lastName: string
632+
}
633+
const formOpts = formOptions({
634+
defaultValues: {
635+
firstName: 'FirstName',
636+
lastName: 'LastName',
637+
} as Person,
638+
})
639+
640+
function Child() {
641+
const form = useTypedAppFormContext(formOpts)
642+
643+
return (
644+
<form.AppField
645+
name="firstName"
646+
children={(field) => <field.TextField label="Testing" />}
647+
/>
648+
)
649+
}
650+
651+
function Parent() {
652+
const form = useAppForm({
653+
defaultValues: {
654+
firstName: 'FirstName',
655+
lastName: 'LastName',
656+
} as Person,
657+
})
658+
659+
return <Child />
660+
}
661+
662+
expect(() => render(<Parent />)).toThrow()
663+
})
664+
665+
it('should allow using typed app form with form components', () => {
666+
type Person = {
667+
firstName: string
668+
lastName: string
669+
}
670+
const formOpts = formOptions({
671+
defaultValues: {
672+
firstName: 'FirstName',
673+
lastName: 'LastName',
674+
} as Person,
675+
})
676+
677+
function Child() {
678+
const form = useTypedAppFormContext(formOpts)
679+
680+
return <form.SubscribeButton label="Testing" />
681+
}
682+
683+
function Parent() {
684+
const form = useAppForm({
685+
defaultValues: {
686+
firstName: 'FirstName',
687+
lastName: 'LastName',
688+
} as Person,
689+
})
690+
691+
return (
692+
<form.AppForm>
693+
<Child />
694+
</form.AppForm>
695+
)
696+
}
697+
698+
const { getByText } = render(<Parent />)
699+
const button = getByText('Testing')
700+
expect(button).toBeInTheDocument()
701+
})
583702
})

0 commit comments

Comments
 (0)