Compose forms with nested form structure #1200
-
The new createFormHook looks great but it appears that it only works when there's a shared data structure between the child and parent forms. The use case where this applies is when you have a section of a form that you want to make reusable. e.g. defaultValues: {
mailingAddress: {
addr1: "",
add2: "",
city: "",
},
billingAddress: {
addr1: "",
add2: "",
city: "",
}
} The only way I can think of to try to make this work currently would be with arrays but seems far from ideal and it breaks down even further depending where you render them on the screen. I think it would be great if somehow the parent form could 'scope' the child form to a specific part of the form state. I think this would make it possible to create truly reusable form components. |
Beta Was this translation helpful? Give feedback.
Replies: 5 comments 10 replies
-
This sounds like a case for a Then you could: <form.AppForm>
<form.AddressSubForm/>
</form.AppForm> |
Beta Was this translation helpful? Give feedback.
-
For the sake of exploration, there exists an alternative approach by using a field component instead of a form component. Here is the example (modified version of the large-form example): import {
useStore,
createFormHook,
createFormHookContexts,
formOptions,
} from '@tanstack/react-form'
export const { fieldContext, useFieldContext, formContext, useFormContext } =
createFormHookContexts()
function TextField({ label }: { label: string }) {
const field = useFieldContext<string>()
const errors = useStore(field.store, (state) => state.meta.errors)
return (
<div>
<label>
<div>{label}</div>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</label>
{errors.map((error: string) => (
<div key={error} style={{ color: 'red' }}>
{error}
</div>
))}
</div>
)
}
// this is the interesting one
function AddressField({ title }: { title: string }) {
// you might want to inline this into the generic parameter of useFieldContext
// but it is declared as an explicit type here for the sake of refering to it later
type Address = {
line1: string,
line2: string,
city: string,
state: string,
zip: string,
};
const field = useFieldContext<Address>();
// prefix/subFormKey can be derived from field name
const prefix = field.name;
// We need to assert, that the form provided by the
// field context is actually a form produced by `useAppForm`
// so that we can use form.AppField
// but unfortunately we loose the type information,
// that at the prefix we have the Address type specified above
const form = field.form as ReturnType<typeof useAppForm>;
return (
<div>
<h2>{title}</h2>
<form.AppField
name={`${prefix}.line1`}
children={(field) => <field.TextField label="Address Line 1" />}
/>
<form.AppField
name={`${prefix}.line2`}
children={(field) => <field.TextField label="Address Line 2" />}
/>
<form.AppField
name={`${prefix}.city`}
children={(field) => <field.TextField label="City" />}
/>
<form.AppField
name={`${prefix}.state`}
children={(field) => <field.TextField label="State" />}
/>
<form.AppField
name={`${prefix}.zip`}
children={(field) => <field.TextField label="ZIP Code" />}
/>
</div>
)
}
function SubscribeButton({ label }: { label: string }) {
const form = useFormContext()
return (
<form.Subscribe selector={(state) => state.isSubmitting}>
{(isSubmitting) => <button disabled={isSubmitting}>{label}</button>}
</form.Subscribe>
)
}
export const { useAppForm, withForm } = createFormHook({
fieldComponents: {
TextField,
AddressField,
},
formComponents: {
SubscribeButton,
},
fieldContext,
formContext,
})
export const peopleFormOpts = formOptions({
defaultValues: {
fullName: '',
homeAddress: {
line1: '',
line2: '',
city: '',
state: '',
zip: '',
},
workAddress: {
line1: '',
line2: '',
city: '',
state: '',
zip: '',
},
},
})
export const PeoplePage = () => {
const form = useAppForm({
...peopleFormOpts,
onSubmit: ({ value }) => {
alert(JSON.stringify(value, null, 2))
},
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
>
<h1>Personal Information</h1>
<form.AppField
name="fullName"
children={(field) => <field.TextField label="Full Name" />}
/>
{/* here we use the AddressField component for homeAddress */}
<form.AppField
name="homeAddress"
children={(field) => <field.AddressField title="Home Address" />}
/>
{/* and here we use it for workAddress */}
<form.AppField
name="workAddress"
children={(field) => <field.AddressField title="Work Address" />}
/>
<form.AppForm>
<form.SubscribeButton label="Submit" />
</form.AppForm>
</form>
)
} Inside the people-form, we treat It is necessary for the "sub-form" (in our case the However, we still lose the type information, that for example at
Anyway, I hope what I have written down here makes sense. At least I made sure that the example above works with @tanstack/form v1.11.1 (commit hash: |
Beta Was this translation helpful? Give feedback.
-
@asegarra Trying to reproduce the above code in my local and when i type in one field every field triggers a rerender. Any chance you know what the issue could be? const ManualAddressField = () => {
const { formatMessage } = useIntl()
const field = useFieldContext<Address>()
const prefix = field.name
const form = field.form as ReturnType<typeof useAppForm>
return (
<Column gap="20px" width="100%">
<Row gap="16px">
<form.AppField
name={`${prefix}.streetAddress`}
children={(field) => <field.TextField label={formatMessage({ defaultMessage: 'Street address' })} autoComplete="street-address" />}
/>
</Row>
<Row gap="16px">
<form.AppField
name={`${prefix}.city`}
children={(field) => <field.TextField label={formatMessage({ defaultMessage: 'City / Suburb' })} autoComplete="address-level2" />}
/>
</Row>
<Row gap="16px">
<form.AppField
name={`${prefix}.province`}
children={(field) => (
<field.TextField label={formatMessage({ defaultMessage: 'State / Province' })} autoComplete="administrative_area_level_1" />
)}
/>
<form.AppField
name={`${prefix}.postCode`}
children={(field) => <field.TextField label={formatMessage({ defaultMessage: 'Post Code / Zip Code' })} autoComplete="postal_code" />}
/>
</Row>
{/* <form.AppField
name={`${prefix}.country`}
children={(field) => (
<field.CountryDropdownField label={formatMessage({ defaultMessage: 'Country of residence' })} autoComplete="country" />
)}
/> */}
</Column>
)
}
export default ManualAddressField import { useStore } from '@tanstack/react-store'
import { Input, InputContainer, type InputProps } from '~/design/components/Input'
import { InputLabelLarge } from '~/design/fonts/input'
import { useFieldContext } from '../general-form-context'
interface TextFieldProps extends Omit<InputProps, 'onChange' | 'onBlur' | 'value'> {
'data-cy'?: string
}
const TextField = (props: TextFieldProps) => {
const { label, autoComplete, ...inputProps } = props
const field = useFieldContext<string>()
const errors = useStore(field.store, (state) => state.meta.errors)
return (
<InputContainer>
<InputLabelLarge htmlFor={field.name} subdued>
{label}
</InputLabelLarge>
<Input
{...inputProps}
value={field.state.value ?? ''}
onBlur={field.handleBlur}
slotProps={{
...inputProps.slotProps,
htmlInput: {
...inputProps.slotProps?.htmlInput,
autoComplete,
},
}}
onChange={(e) => field.handleChange(e.target.value)}
error={errors?.[0]?.message}
/>
</InputContainer>
)
}
export default TextField import { createFormHook } from '@tanstack/react-form'
import { lazy } from 'react'
import { fieldContext, formContext } from './general-form-context'
const TextField = lazy(() => import('./components/TextField'))
const DropdownField = lazy(() => import('./components/DropdownField'))
const CountryDropdownField = lazy(() => import('./components/CountryDropdownField'))
const PhoneNumberField = lazy(() => import('./components/PhoneNumberField'))
const UploadFile = lazy(() => import('./components/UploadFile'))
const PatternField = lazy(() => import('./components/PatternField'))
// const AddressInputField = lazy(() => import('./components/AddressInputField'))
const AddressInputFieldManual = lazy(() => import('./components/AddressInputField/Manual'))
export const { useAppForm, withForm } = createFormHook({
fieldComponents: {
TextField,
CountryDropdownField,
// AddressInputField,
AddressInputFieldManual,
DropdownField,
UploadFile,
PhoneNumberField,
PatternField,
},
formComponents: {},
fieldContext,
formContext,
}) /* Everything around this works fine and rerenders correctly */'
<form.AppField name="address" children={(field) => <field.AddressInputFieldManual />} /> EDIT: Just continuing my testing and wrapping the ManualAddressField in memo noticeably improves the performance but still using react-scan i see rerenders on the entire sub form that is not present when only having one field. Example const fooField = memo(() => {
const field = useFieldContext<{ field1: string, field2: string }>()
const errors = useStore(field.store, (state) => state.meta.errors)
// so on
...
}) |
Beta Was this translation helpful? Give feedback.
-
I have found another approach to this, which seems to be fully type-safe (please correct me if I am wrong). It also works for other frameworks than react, since it does not leverage the Overview:
Here is the example (built on large-form react example, using tanstack-form v1.11.3, commit hash import {
useStore,
createFormHook,
createFormHookContexts,
formOptions,
type DeepKeys,
type FormValidateOrFn,
type FormAsyncValidateOrFn,
type FormOptions,
} from '@tanstack/react-form'
export const { fieldContext, useFieldContext, formContext, useFormContext } =
createFormHookContexts()
function TextField({ label }: { label: string }) {
const field = useFieldContext<string>()
const errors = useStore(field.store, (state) => state.meta.errors)
return (
<div>
<label>
<div>{label}</div>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</label>
{errors.map((error: string) => (
<div key={error} style={{ color: 'red' }}>
{error}
</div>
))}
</div>
)
}
function SubscribeButton({ label }: { label: string }) {
const form = useFormContext()
return (
<form.Subscribe selector={(state) => state.isSubmitting}>
{(isSubmitting) => <button disabled={isSubmitting}>{label}</button>}
</form.Subscribe>
)
}
// interesting part begins here
// utility type
// we want to be able to accept any formOptions type
type AnyFormOptions = FormOptions<
any,
FormValidateOrFn<any> | undefined,
FormValidateOrFn<any> | undefined,
FormAsyncValidateOrFn<any> | undefined,
FormValidateOrFn<any> | undefined,
FormAsyncValidateOrFn<any> | undefined,
FormValidateOrFn<any> | undefined,
FormAsyncValidateOrFn<any> | undefined,
FormAsyncValidateOrFn<any> | undefined,
unknown
>
// utility type
// this is the type of the subform prop that a component can accept
// it is designed to be unionized in order to accept multiple prefixes and multiple forms
// generic parameters:
// - TOpts for formOptions type
// - TPrefix for the prefix of the subform, must be included in TFormData
// - TFormData deduced from TOpts's defaultValues
// properties:
// - form, which has to be of TFormData
// - prefix, which can only be a valid prefix in TFormData
type SubForm<
TOpts extends AnyFormOptions,
TPrefix extends DeepKeys<TFormData>,
TFormData = NonNullable<TOpts['defaultValues']>,
> = {
form: ReturnType<
typeof useAppForm<
TFormData,
FormValidateOrFn<TFormData> | undefined,
FormValidateOrFn<TFormData> | undefined,
FormAsyncValidateOrFn<TFormData> | undefined,
FormValidateOrFn<TFormData> | undefined,
FormAsyncValidateOrFn<TFormData> | undefined,
FormValidateOrFn<TFormData> | undefined,
FormAsyncValidateOrFn<TFormData> | undefined,
FormAsyncValidateOrFn<TFormData> | undefined,
unknown
>
>,
prefix: TPrefix,
};
// utility function
// this one is a little tricky and does 2 things
// 1. if we would just plain do `subform.form`, the form field would have the following type/shape:
// FormApi<TFormData1, ...> | FromApi<TFormData2, ...> | ...
// this type/shape does not allow us to call functions like form.AppField or form.Field (you can try it out)
// rather, the type/shape needs to be something like this:
// FormApi<TFormData1 | TFormData2 | ..., ...>
// in order for us to be able to call `form.AppField` and `form.Field`
// 2. Furthermore, there also has to be a special consideration for
// same prefixes in multiple forms
// given the following example with two forms, TFormData would be the following:
// `{ address: { line1: string, line2: string} } | { address: { line1: string } }`
// they have same prefix `address`, but the second form is missing `line2` field.
// Due to the nature of DeepKeys (and therefore, the `name` prop of `form.Field`),
// when DeepKeys is called with this union, it would return `"address.line2"` as a possible name
// so we explicitly neverize all the primitive fields,
// which are not contained in every element of the union
// so in the above case the neverization would result in the following type:
// `{ address: { line1: {}, line2: never } }`
// Then we intersect it with the above union to get the following TFormData:
// `{ address: { line1: string, line2: never} } | { address: { line1: string, line2: never } }`
// this way, we get an error if we try to access the name `"address.line2"`
function getSubForm<T extends SubForm<any, string>>(subform: T) {
type UnionToIntersection<U> =
(U extends any ? (x: U) => void : never) extends ((x: infer I) => void) ? I : never
type Indexify<T> = T & { [str: string]: undefined; }
type AllUnionKeys<T> = keyof UnionToIntersection<T>
type Neverized<T> = {
[K in AllUnionKeys<T> & string as
(undefined extends Indexify<T>[K]
? (UnionToIntersection<T>[K] extends object
? never
: K)
: K)
]:
(Indexify<T>[K] extends Array<any>
? Neverized<Indexify<T>[K][number]>[]
: (Indexify<T>[K] extends object
? Neverized<Indexify<T>[K]>
: (undefined extends Indexify<T>[K]
? (UnionToIntersection<T>[K] extends object
? unknown
: never)
: unknown)))
}
type FormDataUnion = NonNullable<T['form']['options']['defaultValues']>;
type NeverizedFormData = Neverized<FormDataUnion>
type FormData = FormDataUnion & NeverizedFormData;
return subform.form as ReturnType<
typeof useAppForm<
FormData,
FormValidateOrFn<FormData> | undefined,
FormValidateOrFn<FormData> | undefined,
FormAsyncValidateOrFn<FormData> | undefined,
FormValidateOrFn<FormData> | undefined,
FormAsyncValidateOrFn<FormData> | undefined,
FormValidateOrFn<FormData> | undefined,
FormAsyncValidateOrFn<FormData> | undefined,
FormAsyncValidateOrFn<FormData> | undefined,
unknown
>
>;
}
// now, we use SubForm + getSubForm for this component
function AddressSubForm(
{ title, subform }: {
title: string,
subform:
// here, we can specify, which forms and which field-prefixes we accept
// more specifically:
// we accept "homeAddress" or "workAddress" prefix for peopleFormOpts
// or we accept "address" prefix for testFormOpts
// also I added deep addresses and addresses in arrays in order to confirm it is working
// note for array fields: we could also write `array[${number}]` in order to accept any array index
| SubForm<typeof peopleFormOpts, "homeAddress" | "workAddress" | "array[0]" | "deep.address">
| SubForm<typeof testFormOpts, "address" | "array[0]" | "deep.address">
,
}
) {
// get the prefix
const prefix = subform.prefix;
// get the form in the right type/shape
const form = getSubForm(subform);
return (
<div>
<h2>{title}</h2>
<form.AppField
name={`${prefix}.line1`}
children={(field) => <field.TextField label="Address Line 1" />}
/>
<form.AppField
name={`${prefix}.line2`}
children={(field) => <field.TextField label="Address Line 2" />}
/>
<form.AppField
name={`${prefix}.city`}
children={(field) => <field.TextField label="City" />}
/>
<form.AppField
name={`${prefix}.state`}
children={(field) => <field.TextField label="State" />}
/>
<form.AppField
name={`${prefix}.zip`}
children={(field) => <field.TextField label="ZIP Code" />}
/>
</div>
)
}
// notice here how we do not have to pass
// AddressSubForm to the createFormHook,
// making this approach usable in other frameworks other than react too!
export const { useAppForm, withForm } = createFormHook({
fieldComponents: {
TextField,
},
formComponents: {
SubscribeButton,
},
fieldContext,
formContext,
})
// if we would remove `line1` field from homeAddress or workAddress
// then we would get a type error in AddressSubForm
// specifically, we would get a type error at name={`${prefix}.line1`}
// same with the other fields
export const peopleFormOpts = formOptions({
defaultValues: {
fullName: '',
homeAddress: {
line1: '',
line2: '',
city: '',
state: '',
zip: '',
},
workAddress: {
line1: '',
line2: '',
city: '',
state: '',
zip: '',
},
deep: {
address: {
line1: '',
line2: '',
city: '',
state: '',
zip: '',
},
},
array: [{
line1: '',
line2: '',
city: '',
state: '',
zip: '',
}],
},
})
// this is not really displayed, but I added this in order to test multiple forms
// Also if you were to remove an address sub-field here,
// you get a type error in AddressSubForm
export const testFormOpts = formOptions({
defaultValues: {
address: {
line1: '',
line2: '',
city: '',
state: '',
zip: '',
},
deep: {
address: {
line1: '',
line2: '',
city: '',
state: '',
zip: '',
},
},
array: [{
line1: '',
line2: '',
city: '',
state: '',
zip: '',
}],
},
})
export const PeoplePage = () => {
const form = useAppForm({
...peopleFormOpts,
onSubmit: ({ value }) => {
alert(JSON.stringify(value, null, 2))
},
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
>
<h1>Personal Information</h1>
<form.AppField
name={"fullName"}
children={(field) => { console.log(field.name); return <field.TextField label="Full Name" />; }}
/>
{/* we also get type-safety for the prefix here! */}
<AddressSubForm
title="Home Address"
subform={{ form, prefix: "homeAddress" }}
/>
<AddressSubForm
title="Work Address"
subform={{ form, prefix: "workAddress" }}
/>
{/* also for deeply nested prefixes! */}
<AddressSubForm
title="Deep Address"
subform={{ form, prefix: "deep.address" }}
/>
{/* and for arrays too! */}
<AddressSubForm
title="Array Address"
subform={{ form, prefix: "array[0]" }}
/>
<form.AppForm>
<form.SubscribeButton label="Submit" />
</form.AppForm>
</form>
)
} Further considerations:
|
Beta Was this translation helpful? Give feedback.
-
This feature is currently in the works! You can read up on it in #1469 . Feedback is welcome / appreciated. |
Beta Was this translation helpful? Give feedback.
This sounds like a case for a
form.AppForm
component, not awithForm
component.Then you could: