Skip to content

feat(react-form): Add withFieldGroup #1469

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 47 commits into
base: main
Choose a base branch
from

Conversation

LeCarbonator
Copy link
Contributor

@LeCarbonator LeCarbonator commented Apr 30, 2025

Todos

  • Implementation
  • Unit tests
  • Documentation
  • 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"
/>

Copy link

nx-cloud bot commented Apr 30, 2025

View your CI Pipeline Execution ↗ for commit 8ce40fe.

Command Status Duration Result
nx affected --targets=test:sherif,test:knip,tes... ✅ Succeeded 1m 20s View ↗
nx run-many --target=build --exclude=examples/** ✅ Succeeded 23s View ↗

☁️ Nx Cloud last updated this comment at 2025-06-02 11:15:10 UTC

Copy link

pkg-pr-new bot commented Apr 30, 2025

More templates

@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: 8ce40fe

@LeCarbonator
Copy link
Contributor Author

LeCarbonator commented Apr 30, 2025

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.

While the previous separate implementation was compatible with AppForm, users
wouldn't have been able to use any field/form components
in the render itself. This commit allows them to do that,
at the expense of not being compatible with useForm.
@LeCarbonator LeCarbonator changed the title Draft: Suggestion for Form Group API feat(react-form): Add Form Group API May 1, 2025
@LeCarbonator LeCarbonator marked this pull request as ready for review May 1, 2025 11:46
@LeCarbonator
Copy link
Contributor Author

The unit tests should be reusable in case this isn't the desired approach.

Copy link

codecov bot commented May 1, 2025

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 90.10%. Comparing base (75a442d) to head (8ce40fe).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1469      +/-   ##
==========================================
+ Coverage   89.24%   90.10%   +0.86%     
==========================================
  Files          31       33       +2     
  Lines        1432     1557     +125     
  Branches      366      380      +14     
==========================================
+ Hits         1278     1403     +125     
  Misses        137      137              
  Partials       17       17              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@LeCarbonator
Copy link
Contributor Author

Related PR: #1334

@LeCarbonator
Copy link
Contributor Author

LeCarbonator commented May 2, 2025

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 name path). This can be averted by assuming a path namespace like address.name

@LeCarbonator LeCarbonator marked this pull request as draft May 5, 2025 07:44
This type will help with a lens API for forms
so that only names leading to the subset data
are allowed.
@LeCarbonator
Copy link
Contributor Author

#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.

@LeCarbonator
Copy link
Contributor Author

LeCarbonator commented May 10, 2025

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.

Copy link
Member

@crutchcorn crutchcorn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DUDE - this is awesome. I'm genuinely so excited about it. I know I've gushed about this API in our private maintainer's channel, but want to share that excitement here as well.

Outside of these 7 items I think there's one big one:

I want to change this API's name to fieldGroup, where the lens property becomes group and the withFormLens becomes withFieldGroup.

I think the proposed name change feels more genuine and less CS-focused.

Comment on lines 385 to 410
this.store = new Derived({
deps: [this.form.store],
fn: ({ currDepVals }) => {
const currFormStore = currDepVals[0]
const {
errors: formErrors,
errorMap: formErrorMap,
values: _,
...rest
} = currFormStore

const { errorMap: lensErrorMap, errors: lensErrors } =
this.form.getFieldMeta(this.name) ?? {
...defaultFieldMeta,
}

return {
...rest,
lensErrorMap,
lensErrors,
formErrorMap,
formErrors,
values: this.form.getFieldValue(this.name) as TLensData,
}
},
})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a pretty broad design question that I don't know the answer to yet, so I want you and other @TanStack/form-maintainers to weigh in:

Should we:

  1. Keep the derived state inside of the FormLens class?
  2. Register a FormLens instance to the Form class and compute the derived state inside of the Form class?

We currently do 2 for Fields, but lenses might be sufficiently different from what we want to do.

The reason I loosely lean towards 2 and why I bring it up at all:

The current implementation will cause more re-renders than needed to due lack of caching values from prevDepVals; a pattern we follow for fields (but is tricky to get right).

We can fix this in the current implementation, but feels strange to have fields and lenses behave differently in how they compute their derived state

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thing is, the lens itself should ideally not exist. It should merely be a translator between fields and a form using some form of mapping.

With this in mind, i think I will remove most state values as they are not related to it and can be accessed differently (submissionAttempts getting fetched from group.form.store instead of directly)

However, values would be convenient to have derived somehow for easier useStore and Subscribe. I'm just not sure how to go about it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would lean towards 2 myself, it feels odd to have a separate state outside the form. Though it seems we're all in agreement

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The revised version tries to keep as little store as possible in the field group, since it's not its true purpose. Thoughts? @harry-whorlow @crutchcorn

@crutchcorn
Copy link
Member

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 LeCarbonator changed the title feat(react-form): Add withFormLens feat(react-form): Add withFieldGroup Jun 1, 2025
the method appears to be a helper function for form.reset,
which is accessible from the field group API.
There does not seem to be a good reason to port this method
from FormApi.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants