Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ninety-crews-smash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/react-form': minor
---

add `useTypedAppFormContext`
55 changes: 55 additions & 0 deletions docs/framework/react/guides/form-composition.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, /* ... */ })
// <Outlet /> cannot be customized or receive additional props
return <Outlet />
}
```

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 (
<form.AppForm>
<ChildComponent />
</form.AppForm>
)
}

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.
Expand Down
109 changes: 82 additions & 27 deletions packages/react-form/src/createFormHook.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,33 @@ type UnwrapDefaultOrAny<DefaultT, T> = [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<string, never>,
any,
any,
any,
any,
any,
any,
any,
any,
any,
any,
any
>
}

export function createFormHookContexts() {
function useFieldContext<TData>() {
const field = useContext(fieldContext)
Expand Down Expand Up @@ -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<string, never>,
any,
any,
any,
any,
any,
any,
any,
any,
any,
any,
any
>
}

return { fieldContext, useFieldContext, useFormContext, formContext }
}

Expand Down Expand Up @@ -540,9 +540,64 @@ export function createFormHook<
}
}

/**
* ⚠️ **Use withForm whenever possible.**
*
* Gets a typed form from the `<form.AppForm />` context.
*/
function useTypedAppFormContext<
TFormData,
TOnMount extends undefined | FormValidateOrFn<TFormData>,
TOnChange extends undefined | FormValidateOrFn<TFormData>,
TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
TOnBlur extends undefined | FormValidateOrFn<TFormData>,
TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
TOnSubmit extends undefined | FormValidateOrFn<TFormData>,
TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
TOnDynamic extends undefined | FormValidateOrFn<TFormData>,
TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>,
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,
}
}
139 changes: 129 additions & 10 deletions packages/react-form/tests/createFormHook.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 (
<form.AppField
name="firstName"
children={(field) => <field.TextField label="Testing" />}
/>
)
}

function Parent() {
const form = useAppForm({
defaultValues: {
firstName: 'FirstName',
lastName: 'LastName',
} as Person,
})

return (
<form.AppForm>
<Child />
</form.AppForm>
)
}

const { getByLabelText } = render(<Parent />)
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 (
<form.AppField
name="firstName"
children={(field) => <field.TextField label="Testing" />}
/>
)
}

function Parent() {
const form = useAppForm({
defaultValues: {
firstName: 'FirstName',
lastName: 'LastName',
} as Person,
})

return <Child />
}

expect(() => render(<Parent />)).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 <form.SubscribeButton label="Testing" />
}

function Parent() {
const form = useAppForm({
defaultValues: {
firstName: 'FirstName',
lastName: 'LastName',
} as Person,
})

return (
<form.AppForm>
<Child />
</form.AppForm>
)
}

const { getByText } = render(<Parent />)
const button = getByText('Testing')
expect(button).toBeInTheDocument()
})
})
Loading