Skip to content

feat(react-form): Add withFormLens #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 31 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
187db7b
feat: add createFormGroup
LeCarbonator Apr 30, 2025
5660529
refactor: move createFormGroup to AppForm
LeCarbonator May 1, 2025
072898e
chore: add unit tests for createFormGroup types
LeCarbonator May 1, 2025
9add0bb
chore: add unit test for createFormGroup
LeCarbonator May 1, 2025
6974927
chore: export CreateFormGroupProps
LeCarbonator May 1, 2025
a0238ed
Merge branch 'main' into form-group-api
LeCarbonator May 1, 2025
edd4fcb
add DeepKeysOfType util type
LeCarbonator May 8, 2025
5c7abbf
feat: add initial FormLensApi draft
LeCarbonator May 8, 2025
61c7049
Merge branch 'main' of github.com:TanStack/form into form-group-api
LeCarbonator May 8, 2025
15eecd4
chore: add FormLensApi tests batch
LeCarbonator May 8, 2025
3e99f6f
fix(form-core): fix form.resetField() ignoring nested fields
LeCarbonator May 9, 2025
b8b1179
Merge branch 'fix-gh-1496' of github.com:LeCarbonator/tanstack-form i…
LeCarbonator May 9, 2025
3d14dee
chore: complete form-core unit test for FormLensApi
LeCarbonator May 9, 2025
1900857
feat: add react adapter to form lens api
LeCarbonator May 9, 2025
1eb15e9
fix: fix names for lens.Field and add test
LeCarbonator May 9, 2025
400abf9
chore: export WithFormLensProps
LeCarbonator May 9, 2025
8484d79
Merge branch 'main' into form-group-api
LeCarbonator May 9, 2025
dfe6f03
feat: add Subscribe and store to form lens
LeCarbonator May 10, 2025
019a61d
feat: add mount method to FormLensApi
LeCarbonator May 10, 2025
ea210c7
fix: memoize innerProps to avoid focus loss in withFormLens
LeCarbonator May 11, 2025
4f91b7f
refactor: use single useState instead of multiple useMemos
LeCarbonator May 11, 2025
2eb76fd
feat: allow nesting withFormLenses
LeCarbonator May 11, 2025
3710379
remove createFormGroup for redundancy
LeCarbonator May 11, 2025
e27d1e1
fix: widen typing of lens.Field/AppField to correct level
LeCarbonator May 11, 2025
be0b9d0
docs: add withFormLens section
LeCarbonator May 12, 2025
f749a95
fix: fix TName for lens component
LeCarbonator May 12, 2025
4024901
docs: fix typo in withFormLens
LeCarbonator May 12, 2025
e85a6b7
feat: add lensErrors to FormLensApi store
LeCarbonator May 12, 2025
635619b
chore: adjust memo dependency in useFormLens
LeCarbonator May 13, 2025
4ee6020
chore: call userEvent.setup() in createFormHook tests
LeCarbonator May 13, 2025
c9ed053
Merge branch 'main' into form-group-api
LeCarbonator May 13, 2025
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
156 changes: 156 additions & 0 deletions docs/framework/react/guides/form-composition.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,162 @@ const ChildForm = withForm({
})
```

## 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 `withFormLens` 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 `withFormLens` would look like this:

```tsx
const { useAppForm, withForm, withFormLens } = createFormHook({
fieldComponents: {
TextField,
ErrorInfo,
},
formComponents: {
SubscribeButton,
},
fieldContext,
formContext,
})

type PasswordFields = {
password: string
confirm_password: string
}

// These values are only used for type-checking, and are not used at runtime
// This allows you to `...formOpts` from `formOptions` without needing to redeclare the options
const defaultValues: PasswordFields = {
password: '',
confirm_password: '',
}

const PasswordFields = withFormLens({
defaultValues,
// You may also restrict the lens 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 `lens` instead of a `form`
render: function Render({ lens, title }) {
// access reactive values using the lens store
const password = useStore(lens.store, (state) => state.values.password)
const isSubmitting = useStore(lens.store, (state) => state.isSubmitting)

return (
<div>
<h2>{title}</h2>
{/* Lenses also have access to Field, Subscribe, Field, AppField and AppForm */}
<lens.AppField name="password">
{(field) => <field.TextField label="Password" />}
</lens.AppField>
<lens.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 lens methods instead
if (value !== lens.getFieldValue('password')) {
return 'Passwords do not match'
}
return undefined
},
}}
>
{(field) => (
<div>
<field.TextField label="Confirm Password" />
<field.ErrorInfo />
</div>
)}
</lens.AppField>
</div>
)
},
})
```

We can now use these grouped fields in any form that implements the default values:

```tsx
// You are allowed to extend the lens fields as long as the
// existing properties remain unchanged
type Account = PasswordFields & {
provider: string
username: string
}

// You may nest the lens 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 lens 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
name="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
name={`linked_accounts[${i}]`}
title={account.provider}
/>
))
}
</form.Field>
</form.AppForm>
)
}
```

## Tree-shaking form and field components

While the above examples are great for getting started, they're not ideal for certain use-cases where you might have hundreds of form and field components.
Expand Down
34 changes: 18 additions & 16 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,20 @@ export interface FormListeners<
}) => void
}

/**
* An object representing the base properties of a form, unrelated to any validators
*/
export interface BaseFormOptions<in out TFormData, in out TSubmitMeta = never> {
/**
* Set initial values for your form.
*/
defaultValues?: TFormData
/**
* onSubmitMeta, the data passed from the handleSubmit handler, to the onSubmit function props
*/
onSubmitMeta?: TSubmitMeta
}

/**
* An object representing the options for a form.
*/
Expand All @@ -329,11 +343,7 @@ export interface FormOptions<
in out TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
in out TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>,
in out TSubmitMeta = never,
> {
/**
* Set initial values for your form.
*/
defaultValues?: TFormData
> extends BaseFormOptions<TFormData, TSubmitMeta> {
/**
* The default state for the form.
*/
Expand Down Expand Up @@ -376,11 +386,6 @@ export interface FormOptions<
TOnSubmitAsync
>

/**
* onSubmitMeta, the data passed from the handleSubmit handler, to the onSubmit function props
*/
onSubmitMeta?: TSubmitMeta

/**
* form level listeners
*/
Expand Down Expand Up @@ -2136,12 +2141,9 @@ export class FormApi<
...prev.fieldMetaBase,
[field]: defaultFieldMeta,
},
values: {
...prev.values,
[field]:
this.options.defaultValues &&
this.options.defaultValues[field as keyof TFormData],
},
values: this.options.defaultValues
? setBy(prev.values, field, getBy(this.options.defaultValues, field))
: prev.values,
}
})
}
Expand Down
Loading