feat(react-form): Add `withFieldGroup`
Closes #1296
Todos
- [x] Implementation
- [x] Unit tests
- [x] Documentation
- [x] Fixes
This PR implements a variation of withForm that can be used to create form groups. This form group allows extending defaultValues and has no expectations of form level validators.
This distinguishes it both from withForm as well as instantiations of forms.
Here's an extract of the documentation:
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. Instead of repeating this logic across multiple forms, you can utilize the withFieldGroup higher-order component.
Unlike
withForm, validators cannot be specified and could be any value. Ensure that your fields can accept unknown error types.
Rewriting the passwords example using withFieldGroup would look like this:
const { useAppForm, withForm, withFieldGroup } = createFormHook({
fieldComponents: {
TextField,
ErrorInfo,
},
formComponents: {
SubscribeButton,
},
fieldContext,
formContext,
})
type PasswordFields = {
password: string
confirm_password: string
}
// These default values are not used at runtime, but the keys are needed for mapping purposes.
// This allows you to spread `formOptions` without needing to redeclare it.
const defaultValues: PasswordFields = {
password: '',
confirm_password: '',
}
const FieldGroupPasswordField = withFieldGroup({
defaultValues,
// You may also restrict the group to only use forms that implement this submit meta.
// If none is provided, any form with the right defaultValues may use it.
// onSubmitMeta: { action: '' }
// Optional, but adds props to the `render` function in addition to `form`
props: {
// These default values are also for type-checking and are not used at runtime
title: 'Password',
},
// Internally, you will have access to a `group` instead of a `form`
render: function Render({ group, title }) {
// access reactive values using the group store
const password = useStore(group.store, (state) => state.values.password)
// or the form itself
const isSubmitting = useStore(
group.form.store,
(state) => state.isSubmitting,
)
return (
<div>
<h2>{title}</h2>
{/* Groups also have access to Field, Subscribe, Field, AppField and AppForm */}
<group.AppField name="password">
{(field) => <field.TextField label="Password" />}
</group.AppField>
<group.AppField
name="confirm_password"
validators={{
onChangeListenTo: ['password'],
onChange: ({ value, fieldApi }) => {
// The form could be any values, so it is typed as 'unknown'
const values: unknown = fieldApi.form.state.values
// use the group methods instead
if (value !== group.getFieldValue('password')) {
return 'Passwords do not match'
}
return undefined
},
}}
>
{(field) => (
<div>
<field.TextField label="Confirm Password" />
<field.ErrorInfo />
</div>
)}
</group.AppField>
</div>
)
},
})
We can now use these grouped fields in any form that implements the default values:
// You are allowed to extend the group fields as long as the
// existing properties remain unchanged
type Account = PasswordFields & {
provider: string
username: string
}
// You may nest the group fields wherever you want
type FormValues = {
name: string
age: number
account_data: PasswordFields
linked_accounts: Account[]
}
const defaultValues: FormValues = {
name: '',
age: 0,
account_data: {
password: '',
confirm_password: '',
},
linked_accounts: [
{
provider: 'TanStack',
username: '',
password: '',
confirm_password: '',
},
],
}
function App() {
const form = useAppForm({
defaultValues,
// If the group didn't specify an `onSubmitMeta` property,
// the form may implement any meta it wants.
// Otherwise, the meta must be defined and match.
onSubmitMeta: { action: '' },
})
return (
<form.AppForm>
<PasswordFields
form={form}
// You must specify where the fields can be found
fields="account_data"
title="Passwords"
/>
<form.Field name="linked_accounts" mode="array">
{(field) =>
field.state.value.map((account, i) => (
<PasswordFields
key={account.provider}
form={form}
// The fields may be in nested fields
fields={`linked_accounts[${i}]`}
title={account.provider}
/>
))
}
</form.Field>
</form.AppForm>
)
}
Mapping field group values to a different field
You may want to keep the password fields on the top level of your form, or rename the properties for clarity. You can map field group values
to their true location by changing the field property:
[!IMPORTANT] Due to TypeScript limitations, field mapping is only allowed for objects. You can use records or arrays at the top level of a field group, but you will not be able to map the fields.
// To have an easier form, you can keep the fields on the top level
type FormValues = {
name: string
age: number
password: string
confirm_password: string
}
const defaultValues: FormValues = {
name: '',
age: 0,
password: '',
confirm_password: '',
}
function App() {
const form = useAppForm({
defaultValues,
})
return (
<form.AppForm>
<PasswordFields
form={form}
// You can map the fields to their equivalent deep key
fields={{
password: 'password',
confirm_password: 'confirm_password',
// or map them to differently named keys entirely
// 'password': 'name'
}}
title="Passwords"
/>
</form.AppForm>
)
}
If you expect your fields to always be at the top level of your form, you can create a quick map of your field groups using a helper function:
const defaultValues: PasswordFields = {
password: '',
confirm_password: '',
}
const passwordFields = createFieldMap(defaultValues)
/* This generates the following map:
{
'password': 'password',
'confirm_password': 'confirm_password'
}
*/
// Usage:
<PasswordFields
form={form}
fields={passwordFields}
title="Passwords"
/>
View your CI Pipeline Execution ↗ for commit c09f683b6164e3bcce4ccb2e5ed9354ee1779f58
| Command | Status | Duration | Result |
|---|---|---|---|
nx affected --targets=test:sherif,test:knip,tes... |
✅ Succeeded | 2m 2s | View ↗ |
nx run-many --target=build --exclude=examples/** |
✅ Succeeded | <1s | View ↗ |
☁️ Nx Cloud last updated this comment at 2025-07-13 13:22:19 UTC
More templates
- @tanstack/form-example-angular-array
- @tanstack/form-example-angular-large-form
- @tanstack/form-example-angular-simple
- @tanstack/form-example-lit-simple
- @tanstack/form-example-lit-ui-libraries
- @tanstack/form-example-react-array
- @tanstack/form-example-react-compiler
- @tanstack/field-errors-from-form-validators
- @tanstack/form-example-react-large-form
- @tanstack/form-example-react-next-server-actions
- @tanstack/form-example-react-query-integration
- @tanstack/form-example-remix
- @tanstack/form-example-react-simple
- @tanstack/form-example-react-standard-schema
- @tanstack/form-example-react-tanstack-start
- @tanstack/form-example-react-ui-libraries
- @tanstack/form-example-solid-array
- @tanstack/form-example-solid-large-form
- @tanstack/form-example-solid-simple
- @tanstack/form-example-svelte-array
- @tanstack/form-example-svelte-simple
- @tanstack/form-example-vue-array
- @tanstack/form-example-vue-simple
@tanstack/angular-form
npm i https://pkg.pr.new/@tanstack/angular-form@1469
@tanstack/form-core
npm i https://pkg.pr.new/@tanstack/form-core@1469
@tanstack/lit-form
npm i https://pkg.pr.new/@tanstack/lit-form@1469
@tanstack/react-form
npm i https://pkg.pr.new/@tanstack/react-form@1469
@tanstack/solid-form
npm i https://pkg.pr.new/@tanstack/solid-form@1469
@tanstack/svelte-form
npm i https://pkg.pr.new/@tanstack/svelte-form@1469
@tanstack/vue-form
npm i https://pkg.pr.new/@tanstack/vue-form@1469
commit: c09f683
Issues that could be addressed:
- ~~If the instantiated form has
onSubmitMeta, it's no longer compatible with the form group. Perhaps if onSubmitMeta is unset and/or not called inside form Group, it should allow it outside.~~
This has now been addressed. If onSubmitMeta is unset, any value will do. If it is set, you must match it exactly.
The unit tests should be reusable in case this isn't the desired approach.
Codecov Report
All modified and coverable lines are covered by tests :white_check_mark:
Project coverage is 90.35%. Comparing base (
117b743) to head (c09f683). Report is 4 commits behind head on main.
Additional details and impacted files
@@ Coverage Diff @@
## main #1469 +/- ##
==========================================
+ Coverage 89.55% 90.35% +0.80%
==========================================
Files 34 36 +2
Lines 1493 1617 +124
Branches 370 384 +14
==========================================
+ Hits 1337 1461 +124
Misses 139 139
Partials 17 17
:umbrella: View full report in Codecov by Sentry.
:loudspeaker: Have feedback on the report? Share it here.
:rocket: New features to boost your workflow:
- :snowflake: Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
- :package: JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
Related PR: #1334
Something to consider:
- You cannot nest these form groups (Address form group vs. Address form group in an array)
- They can be conflicting with other groups (two groups sharing the
namepath). This can be averted by assuming a path namespace likeaddress.name
#1475 sounded like an interesting idea, so I'll try to tinker with that and come up with unit tests. The note at the top of the PR still applies.
Strange, the derived state from the form lens isn't updating ... Looks like it's because i'm not mounting it.
Issue: React input unfocuses when changing input. Unsure why.
~~There's currently a difference between a field's name and its actual field name. Looking into it later~~
It's because it forwards the form's Field component. I would much rather not mess with that as the lens should not have separate API as much as possible, especially on the React side of things.
reminding myself for tomorrow. Definitely not useable like this.
After some discussion with @LeCarbonator - we thought of a feature addition:
const Parent = () => {
// Docs change: Prefix all `withFieldGroup` components with `FieldGroup` to avoid confusion
const FieldGroupPassword = withFieldGroup({
defaultValues: {
firstName: '',
lastName: '',
},
render: ({ group, title }) => {
// ...
},
})
const form = useAppForm({
...formOpts,
})
// This is actually pretty type-safe since we'll get an error if the shape of the `fields` doesn't match
// the form name type.
export const fields = {
"password": 'password',
"confirm_password": 'confirm_password',
} as const;
return (
<FieldGroupPassword
form={form}
{/* Rename the `name`*/}
{/* Either */}
fields="person"
{/* OR */}
fields={fields}
{/* Via an overload */}
/>
)
}
@LeCarbonator I've been toying this PR (I know it's not done) because it's exactly what I need. I'm not an experienced developer by any means, but I am a product manager by trade and so I'm used to thinking about user experience or in this case developer experience. Can I suggest that instead a fields prop that either takes a string or an object of key value pairs where you map a form property to the group property, could we instead have a group prop and we pass the actual form object to a group prop or map the destination property to the form property directly? That could be more intuitive since we pass the form prop to the withForm hook and this would feel similar in experience. I don't know the feasibility with that and maintaining type safety, though.
@MPiland could you provide a code snippet of what that structure may look like? It doesn't need to be feasible, just a snippet of what you envisioned the API to be
@LeCarbonator I think there's a couple ways that could maybe work. The prop could be just named group that could either accept an object or an object of properties mapped to form properties. This would probably require a form subscribe.
/* As object */
<form.Subscribe select={(state) => state.values)}>
{(values) => {
<Auth group={values.auth} form={form} />
}
</form.Subscribe>
/* As properties mapped to form properties */
<form.Subscribe select={(state) => state.values)}>
{(values) => {
<Auth group={{username: values.username, password: values.password}} form={form} />
}
</form.Subscribe>
Alternatively, you could add a new option to the createFormHook called groupComponents. They could be called similar to field components, but their name prop could take a string or an object of key values similar to how the withFieldGroup works. Then it could use a useGroupContext. This is all completely brainstorming so it may not be possible. But you could call it like
<form.AppGroup name='auth'>
{({ AuthGroup }) => (
<AuthGroup {...props} />
)} />
</form.AppGroup>
/* or */
<form.AppGroup name={{ username: 'username', password: 'password' }}>
{( group ) => (
<group.AuthGroup {...props} />
)} />
</form.AppGroup>
/* Defining it could be like this */
export const AuthGroup = () => {
const {state, group, AppField} = useGroupContext<AuthGroupType>()
<state.AppField name='password'>
{( field) => (
<field.TextInput label='Password' />
)}
</state.AppField>
}
/* or */
<form.Group name='auth'> // Alternatively <form.Group name={{ password: 'password' }}>
{( group ) => (
<group.Field name='password'>
.....
</group.Field>
)}
</form.Group>
The current approach still works, though, and if that makes more sense to other users, I'm ok being told I'm wrong. I'm just throwing out some ideas. I really like this library. It took me a minute to get it, but once I did it's very intuitive. I just need this last group feature to be able to use it.
@MPiland Alright, here's my thoughts on the snippets:
<form.Subscribe select={(state) => state.values)}>
{(values) => {
<Auth group={values.auth} form={form} />
}
</form.Subscribe>
I'm heavily against this implementation. The reason is that field groups aren't just displaying values, they map fields called within to the form outside. This would force it to be a one-way street which goes against a lot of the features other users want.
As for <form.AppGroup/>, I can see it working, but the benefits aren't very clear to me.
The reason AppField exists is because you have more flexibility for how you implement it:
/* Do you want granular control? */
<form.AppField name="firstName">
{field => <>
<label>First Name</label>
<field.TextInput />
<field.Feedback />
</>}
</form.AppField>
/* Or wrap it all in a component? */
<form.AppField name="firstName">
{field => <field.TextInput showFeedback label="First Name" />}
</form.AppField>
Field groups don't really fit that category, as they're meant to do the work themselves. Once you have the field group, there's really only one way to use it, and that is like you showed:
/* Combinations don't really exist. Either you use the field group or you don't */
<form.AppGroup name='auth'>
{({ AuthGroup }) => (
<AuthGroup {...props} />
)} />
</form.AppGroup>
That's why I prefer the HOC implementation. Hopefully it's clear what I'm trying to convey.
@LeCarbonator That all makes sense to me! I will defer to your expertise. I think the original way works as well. I appreciate you entertaining my thoughts!
Will this feature work if multiple forms share the same UI but have different fields? In our case, each form is divided into tabs, each rendering a form with Section A and Section B. Section B shares the same UI across forms, but some fields are present only in certain forms.
Example: Cycling From — Section B: calories, elevationGain, distance Swimming Form — Section B: calories, distance Gym Form — Section B: calories
Will this feature work if multiple forms share the same UI but have different fields? In our case, each form is divided into tabs, each rendering a form with Section A and Section B. Section B shares the same UI across forms, but some fields are present only in certain forms.
Example: Cycling From — Section B: calories, elevationGain, distance Swimming Form — Section B: calories, distance Gym Form — Section B: calories
Not with its current implementation, no. The intent of this feature is to group multiple fields together so they can all be reused. The Section B you describe is not consistent across forms, but is instead a collection of a calories field, a elevationGain field and a distance field.
Since it's only single fields that remain consistent across forms, consider using a Field Component instead.
An example usage of this field group would be:
Section: calories, elevationGain, distance
- Cycling Form -
calories,elevationGain,distance - Swimming Form -
data.calories,data.elevationGain,data.distance(namespaced withdata) - Gym Form -
gymCalories,gymElevationGain,gymDistance(mapped tocalories,elevationGain,distance)
The inferred type of useAppForm cannot be named without a reference to ../../../node_modules/@tanstack/react-form/dist/esm/createFormHook . This is likely not portable. A type annotation is necessary.
It goes for all createFormHook extracts.
Good catch! I'll add those annotations tomorrow (alongside fixing the failing unit tests at the moment)
As discussed on Discord, it appears to have been because of pnpm cache instead of a problem with the PR.
@LeCarbonator Firstly, thanks so much for putting this together. I've been really looking forward to this feature! 🙌
Just wondering if there's anything blocking this from being merged, or if any help is needed to move it forward. Also curious if there's any idea of when it might be released (no pressure, just excited to try it out!).
@AfrazHussain not particularly. Things have slowed down a notch since it is the middle of summer, but apart from reviewing the revised version, there's no blocking part of this PR.
The PR is installable using the scripts provided by this comment. Of course it's not part of the main branch yet, but it's testable.
I'm sure it will be released soon! If you notice a problem in the current code, feel free to leave a review comment so it can be addressed.
The PR is installable using the scripts provided by https://github.com/TanStack/form/pull/1469#issuecomment-2843118451.
Ah, that is quite helpful. Thanks a lot! I'll try it out and then report back if there's any feedback/issues.
The inferred type of useAppForm cannot be named without a reference to ../../../node_modules/@tanstack/react-form/dist/esm/createFormHook . This is likely not portable. A type annotation is necessary.It goes for all createFormHook extracts.
Did you manage to figure it out? I don't understand why you were ignored, just to stumble across the test boxes.
@ArtemFH The discussion was continued on Discord, so it looks like it was lost here.
tsconfig.json likely has declaration: true enabled, and TypeScript warns you that those generated declaration files will not be able to name the exported functions.
We'll fix it by exporting the types very soon, but in case you need a current workaround, that configuration should be turned off.
What's the recommended workaround for those that do need to emit declarations?
If there's none, it's ok. I'll wait for the types to be exported.
No pretty ones apart from going into TanStack Form's declaration files and exporting the reported types @zhouzi
@LeCarbonator you mentioned this api is intended to be used when fields are closely related but that's still not so clear to me. Could you give examples of use cases? Also, could that be used for a multi-step form?