TanStack Form 常被批评之处在于其开箱即用的冗长。虽然这可能有助于教学——帮助巩固对 API 的理解——但在生产用例中并非理想选择。
因此,虽然 form.Field 实现了 TanStack Form 最强大、最灵活的用法,但我们也提供了封装它的 API,以减少应用程序代码的冗长。
组合表单最强大的方法是创建自定义表单 Hook。这允许您创建一个针对应用程序需求的表单 Hook,包括预绑定的自定义 UI 组件等。
最基本地,createFormHook 是一个函数,它接受一个 fieldContext 和 formContext,并返回一个 useAppForm Hook。
这个未经定制的 useAppForm Hook 与 useForm 完全相同,但随着我们向 createFormHook 添加更多选项,这种情况将很快改变。
import { createFormHookContexts, createFormHook } from '@tanstack/react-form'
// export useFieldContext for use in your custom components
export const { fieldContext, formContext, useFieldContext } =
createFormHookContexts()
const { useAppForm } = createFormHook({
fieldContext,
formContext,
// We'll learn more about these options later
fieldComponents: {},
formComponents: {},
})
function App() {
const form = useAppForm({
// Supports all useForm options
defaultValues: {
firstName: 'John',
lastName: 'Doe',
},
})
return <form.Field /> // ...
}
import { createFormHookContexts, createFormHook } from '@tanstack/react-form'
// export useFieldContext for use in your custom components
export const { fieldContext, formContext, useFieldContext } =
createFormHookContexts()
const { useAppForm } = createFormHook({
fieldContext,
formContext,
// We'll learn more about these options later
fieldComponents: {},
formComponents: {},
})
function App() {
const form = useAppForm({
// Supports all useForm options
defaultValues: {
firstName: 'John',
lastName: 'Doe',
},
})
return <form.Field /> // ...
}
一旦这些脚手架到位,您就可以开始为您的表单 Hook 添加自定义字段和表单组件。
注意:useFieldContext 必须是与您自定义表单上下文导出的那个相同的。
import { useFieldContext } from './form-context.tsx'
export function TextField({ label }: { label: string }) {
// The `Field` infers that it should have a `value` type of `string`
const field = useFieldContext<string>()
return (
<label>
<span>{label}</span>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</label>
)
}
import { useFieldContext } from './form-context.tsx'
export function TextField({ label }: { label: string }) {
// The `Field` infers that it should have a `value` type of `string`
const field = useFieldContext<string>()
return (
<label>
<span>{label}</span>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</label>
)
}
然后,您可以将此组件注册到您的表单 Hook。
import { TextField } from './text-field.tsx'
const { useAppForm } = createFormHook({
fieldContext,
formContext,
fieldComponents: {
TextField,
},
formComponents: {},
})
import { TextField } from './text-field.tsx'
const { useAppForm } = createFormHook({
fieldContext,
formContext,
fieldComponents: {
TextField,
},
formComponents: {},
})
并在您的表单中使用它
function App() {
const form = useAppForm({
defaultValues: {
firstName: 'John',
lastName: 'Doe',
},
})
return (
// Notice the `AppField` instead of `Field`; `AppField` provides the required context
<form.AppField
name="firstName"
children={(field) => <field.TextField label="First Name" />}
/>
)
}
function App() {
const form = useAppForm({
defaultValues: {
firstName: 'John',
lastName: 'Doe',
},
})
return (
// Notice the `AppField` instead of `Field`; `AppField` provides the required context
<form.AppField
name="firstName"
children={(field) => <field.TextField label="First Name" />}
/>
)
}
这不仅允许您重用共享组件的 UI,而且保留了您期望从 TanStack Form 中获得的类型安全:输入 name 并获得 TypeScript 错误。
虽然 Context 是 React 生态系统中一个有价值的工具,但许多用户对通过 Context 提供响应式值会导致不必要的重新渲染感到担忧。
不熟悉这种性能担忧?Mark Erikson 关于 Redux 如何解决这些问题的博客文章 是一个很好的起点。
虽然这是一个值得关注的问题,但对于 TanStack Form 来说并非如此;通过 Context 提供的 is 值本身不是响应式的,而是具有响应式属性的静态类实例(我们使用 TanStack Store 作为信号实现来提供支持)。
虽然 form.AppField 解决了 Field 样板和可重用性方面的许多问题,但它并没有解决表单的样板和可重用性问题。
特别是,能够共享 form.Subscribe 的实例,例如用于响应式的表单提交按钮,是一个常见用例。
function SubscribeButton({ label }: { label: string }) {
const form = useFormContext()
return (
<form.Subscribe selector={(state) => state.isSubmitting}>
{(isSubmitting) => (
<button type="submit" disabled={isSubmitting}>
{label}
</button>
)}
</form.Subscribe>
)
}
const { useAppForm, withForm } = createFormHook({
fieldComponents: {},
formComponents: {
SubscribeButton,
},
fieldContext,
formContext,
})
function App() {
const form = useAppForm({
defaultValues: {
firstName: 'John',
lastName: 'Doe',
},
})
return (
<form.AppForm>
// Notice the `AppForm` component wrapper; `AppForm` provides the required
context
<form.SubscribeButton label="Submit" />
</form.AppForm>
)
}
function SubscribeButton({ label }: { label: string }) {
const form = useFormContext()
return (
<form.Subscribe selector={(state) => state.isSubmitting}>
{(isSubmitting) => (
<button type="submit" disabled={isSubmitting}>
{label}
</button>
)}
</form.Subscribe>
)
}
const { useAppForm, withForm } = createFormHook({
fieldComponents: {},
formComponents: {
SubscribeButton,
},
fieldContext,
formContext,
})
function App() {
const form = useAppForm({
defaultValues: {
firstName: 'John',
lastName: 'Doe',
},
})
return (
<form.AppForm>
// Notice the `AppForm` component wrapper; `AppForm` provides the required
context
<form.SubscribeButton label="Submit" />
</form.AppForm>
)
}
有时表单会变得非常大;事情就是这样。虽然 TanStack Form 可以很好地处理大型表单,但处理包含数百或数千行代码的文件从来都不是一件有趣的事。
为了解决这个问题,我们支持使用 withForm 高阶组件将表单分解成更小的部分。
const { useAppForm, withForm } = createFormHook({
fieldComponents: {
TextField,
},
formComponents: {
SubscribeButton,
},
fieldContext,
formContext,
})
const ChildForm = withForm({
// 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
defaultValues: {
firstName: 'John',
lastName: 'Doe',
},
// Optional, but adds props to the `render` function in addition to `form`
props: {
// These props are also set as default values for the `render` function
title: 'Child Form',
},
render: function Render({ form, title }) {
return (
<div>
<p>{title}</p>
<form.AppField
name="firstName"
children={(field) => <field.TextField label="First Name" />}
/>
<form.AppForm>
<form.SubscribeButton label="Submit" />
</form.AppForm>
</div>
)
},
})
function App() {
const form = useAppForm({
defaultValues: {
firstName: 'John',
lastName: 'Doe',
},
})
return <ChildForm form={form} title={'Testing'} />
}
const { useAppForm, withForm } = createFormHook({
fieldComponents: {
TextField,
},
formComponents: {
SubscribeButton,
},
fieldContext,
formContext,
})
const ChildForm = withForm({
// 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
defaultValues: {
firstName: 'John',
lastName: 'Doe',
},
// Optional, but adds props to the `render` function in addition to `form`
props: {
// These props are also set as default values for the `render` function
title: 'Child Form',
},
render: function Render({ form, title }) {
return (
<div>
<p>{title}</p>
<form.AppField
name="firstName"
children={(field) => <field.TextField label="First Name" />}
/>
<form.AppForm>
<form.SubscribeButton label="Submit" />
</form.AppForm>
</div>
)
},
})
function App() {
const form = useAppForm({
defaultValues: {
firstName: 'John',
lastName: 'Doe',
},
})
return <ChildForm form={form} title={'Testing'} />
}
为什么选择高阶组件而不是 Hook?
虽然 Hook 是 React 的未来,但高阶组件仍然是组合的强大工具。特别是,withForm 的 API 使我们能够在不要求用户传递泛型的情况下实现强大的类型安全性。
为什么我在 render 中收到关于 Hook 的 ESLint 错误?
ESLint 在函数顶层查找 Hook,并且 render 可能不会被识别为顶层组件,具体取决于您的定义方式。
// This will cause ESLint errors with hooks usage
const ChildForm = withForm({
// ...
render: ({ form, title }) => {
// ...
},
})
// This will cause ESLint errors with hooks usage
const ChildForm = withForm({
// ...
render: ({ form, title }) => {
// ...
},
})
// This works fine
const ChildForm = withForm({
// ...
render: function Render({ form, title }) {
// ...
},
})
// This works fine
const ChildForm = withForm({
// ...
render: function Render({ form, title }) {
// ...
},
})
有时,一对字段非常相关,以至于将它们分组和重用是很有意义的——就像在 链接字段指南 中列出的密码示例一样。您可以利用 withFieldGroup 高阶组件,而不是在多个表单中重复此逻辑。
与 withForm 不同,无法指定验证器,并且可以是任何值。确保您的字段可以接受未知错误类型。
使用 withFieldGroup 重写密码示例如下:
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 FieldGroupPasswordFields = 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>
)
},
})
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 FieldGroupPasswordFields = 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>
)
},
})
我们现在可以在实现默认值的任何表单中使用这些分组的字段
// 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>
<FieldGroupPasswordFields
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) => (
<FieldGroupPasswordFields
key={account.provider}
form={form}
// The fields may be in nested fields
fields={`linked_accounts[${i}]`}
title={account.provider}
/>
))
}
</form.Field>
</form.AppForm>
)
}
// 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>
<FieldGroupPasswordFields
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) => (
<FieldGroupPasswordFields
key={account.provider}
form={form}
// The fields may be in nested fields
fields={`linked_accounts[${i}]`}
title={account.provider}
/>
))
}
</form.Field>
</form.AppForm>
)
}
您可能希望将密码字段保留在表单的顶层,或重命名属性以提高清晰度。通过更改 field 属性,您可以将字段组的值映射到其真实位置。
重要
由于 TypeScript 的限制,字段映射仅允许用于对象。您可以在字段组的顶层使用记录或数组,但不能映射字段。
// 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>
<FieldGroupPasswordFields
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>
)
}
// 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>
<FieldGroupPasswordFields
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>
)
}
如果您希望您的字段始终位于表单的顶层,您可以使用辅助函数快速映射字段组。
const defaultValues: PasswordFields = {
password: '',
confirm_password: '',
}
const passwordFields = createFieldMap(defaultValues)
/* This generates the following map:
{
'password': 'password',
'confirm_password': 'confirm_password'
}
*/
// Usage:
<FieldGroupPasswordFields
form={form}
fields={passwordFields}
title="Passwords"
/>
const defaultValues: PasswordFields = {
password: '',
confirm_password: '',
}
const passwordFields = createFieldMap(defaultValues)
/* This generates the following map:
{
'password': 'password',
'confirm_password': 'confirm_password'
}
*/
// Usage:
<FieldGroupPasswordFields
form={form}
fields={passwordFields}
title="Passwords"
/>
虽然上面的示例很适合入门,但对于您可能有数百个表单和字段组件的某些用例来说,它们并非理想选择。特别是,您可能不希望将所有表单和字段组件都包含在使用表单 Hook 的每个文件的包中。
为了解决这个问题,您可以将 createFormHook TanStack API 与 React 的 lazy 和 Suspense 组件结合使用。
// src/hooks/form-context.ts
import { createFormHookContexts } from '@tanstack/react-form'
export const { fieldContext, useFieldContext, formContext, useFormContext } =
createFormHookContexts()
// src/hooks/form-context.ts
import { createFormHookContexts } from '@tanstack/react-form'
export const { fieldContext, useFieldContext, formContext, useFormContext } =
createFormHookContexts()
// src/components/text-field.tsx
import { useFieldContext } from '../hooks/form-context.tsx'
export default function TextField({ label }: { label: string }) {
const field = useFieldContext<string>()
return (
<label>
<span>{label}</span>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</label>
)
}
// src/components/text-field.tsx
import { useFieldContext } from '../hooks/form-context.tsx'
export default function TextField({ label }: { label: string }) {
const field = useFieldContext<string>()
return (
<label>
<span>{label}</span>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</label>
)
}
// src/hooks/form.ts
import { lazy } from 'react'
import { createFormHook } from '@tanstack/react-form'
const TextField = lazy(() => import('../components/text-fields.tsx'))
const { useAppForm, withForm } = createFormHook({
fieldContext,
formContext,
fieldComponents: {
TextField,
},
formComponents: {},
})
// src/hooks/form.ts
import { lazy } from 'react'
import { createFormHook } from '@tanstack/react-form'
const TextField = lazy(() => import('../components/text-fields.tsx'))
const { useAppForm, withForm } = createFormHook({
fieldContext,
formContext,
fieldComponents: {
TextField,
},
formComponents: {},
})
// src/App.tsx
import { Suspense } from 'react'
import { PeoplePage } from './features/people/form.tsx'
export default function App() {
return (
<Suspense fallback={<p>Loading...</p>}>
<PeoplePage />
</Suspense>
)
}
// src/App.tsx
import { Suspense } from 'react'
import { PeoplePage } from './features/people/form.tsx'
export default function App() {
return (
<Suspense fallback={<p>Loading...</p>}>
<PeoplePage />
</Suspense>
)
}
这将显示 Suspense fallbac,同时 TextField 组件正在加载,然后在加载完成后渲染表单。
现在我们已经涵盖了创建自定义表单 Hook 的基础知识,让我们在一个示例中将它们整合起来。
// /src/hooks/form.ts, to be used across the entire app
const { fieldContext, useFieldContext, formContext, useFormContext } =
createFormHookContexts()
function TextField({ label }: { label: string }) {
const field = useFieldContext<string>()
return (
<label>
<span>{label}</span>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</label>
)
}
function SubscribeButton({ label }: { label: string }) {
const form = useFormContext()
return (
<form.Subscribe selector={(state) => state.isSubmitting}>
{(isSubmitting) => <button disabled={isSubmitting}>{label}</button>}
</form.Subscribe>
)
}
const { useAppForm, withForm } = createFormHook({
fieldComponents: {
TextField,
},
formComponents: {
SubscribeButton,
},
fieldContext,
formContext,
})
// /src/features/people/shared-form.ts, to be used across `people` features
const formOpts = formOptions({
defaultValues: {
firstName: 'John',
lastName: 'Doe',
},
})
// /src/features/people/nested-form.ts, to be used in the `people` page
const ChildForm = withForm({
...formOpts,
// Optional, but adds props to the `render` function outside of `form`
props: {
title: 'Child Form',
},
render: ({ form, title }) => {
return (
<div>
<p>{title}</p>
<form.AppField
name="firstName"
children={(field) => <field.TextField label="First Name" />}
/>
<form.AppForm>
<form.SubscribeButton label="Submit" />
</form.AppForm>
</div>
)
},
})
// /src/features/people/page.ts
const Parent = () => {
const form = useAppForm({
...formOpts,
})
return <ChildForm form={form} title={'Testing'} />
}
// /src/hooks/form.ts, to be used across the entire app
const { fieldContext, useFieldContext, formContext, useFormContext } =
createFormHookContexts()
function TextField({ label }: { label: string }) {
const field = useFieldContext<string>()
return (
<label>
<span>{label}</span>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</label>
)
}
function SubscribeButton({ label }: { label: string }) {
const form = useFormContext()
return (
<form.Subscribe selector={(state) => state.isSubmitting}>
{(isSubmitting) => <button disabled={isSubmitting}>{label}</button>}
</form.Subscribe>
)
}
const { useAppForm, withForm } = createFormHook({
fieldComponents: {
TextField,
},
formComponents: {
SubscribeButton,
},
fieldContext,
formContext,
})
// /src/features/people/shared-form.ts, to be used across `people` features
const formOpts = formOptions({
defaultValues: {
firstName: 'John',
lastName: 'Doe',
},
})
// /src/features/people/nested-form.ts, to be used in the `people` page
const ChildForm = withForm({
...formOpts,
// Optional, but adds props to the `render` function outside of `form`
props: {
title: 'Child Form',
},
render: ({ form, title }) => {
return (
<div>
<p>{title}</p>
<form.AppField
name="firstName"
children={(field) => <field.TextField label="First Name" />}
/>
<form.AppForm>
<form.SubscribeButton label="Submit" />
</form.AppForm>
</div>
)
},
})
// /src/features/people/page.ts
const Parent = () => {
const form = useAppForm({
...formOpts,
})
return <ChildForm form={form} title={'Testing'} />
}
这是一个图表,可以帮助您决定应使用哪些 API。
您的每周 JavaScript 资讯。每周一免费发送给超过 10 万开发者。