表单组合

TanStack Form 的一个常见批评是其开箱即用的冗长性。虽然这_可能_对教育目的有用 - 有助于强化对我们 API 的理解 - 但在生产用例中并不理想。

因此,虽然 form.Field 能够实现 TanStack Form 最强大和灵活的使用,但我们提供了包装它的 API,使您的应用程序代码不那么冗长。

自定义表单钩子

组合表单最强大的方式是创建自定义表单钩子。这允许您创建一个针对应用程序需求量身定制的表单钩子,包括预绑定的自定义 UI 组件等。

在最基本的层面上,createFormHook 是一个接受 fieldContextformContext 并返回 useAppForm 钩子的函数。

这个未自定义的 useAppForm 钩子与 useForm 相同,但随着我们向 createFormHook 添加更多选项,这将很快改变。

tsx
import { createFormHookContexts, createFormHook } from '@tanstack/react-form'

// 导出 useFieldContext 以在您的自定义组件中使用
export const { fieldContext, formContext, useFieldContext } =
  createFormHookContexts()

const { useAppForm } = createFormHook({
  fieldContext,
  formContext,
  // 我们稍后会了解更多关于这些选项的信息
  fieldComponents: {},
  formComponents: {},
})

function App() {
  const form = useAppForm({
    // 支持所有 useForm 选项
    defaultValues: {
      firstName: 'John',
      lastName: 'Doe',
    },
  })

  return <form.Field /> // ...
}
import { createFormHookContexts, createFormHook } from '@tanstack/react-form'

// 导出 useFieldContext 以在您的自定义组件中使用
export const { fieldContext, formContext, useFieldContext } =
  createFormHookContexts()

const { useAppForm } = createFormHook({
  fieldContext,
  formContext,
  // 我们稍后会了解更多关于这些选项的信息
  fieldComponents: {},
  formComponents: {},
})

function App() {
  const form = useAppForm({
    // 支持所有 useForm 选项
    defaultValues: {
      firstName: 'John',
      lastName: 'Doe',
    },
  })

  return <form.Field /> // ...
}

预绑定字段组件

一旦这个脚手架就位,您就可以开始向表单钩子添加自定义字段和表单组件。

注意:useFieldContext 必须是从您的自定义表单上下文导出的同一个

tsx
import { useFieldContext } from './form-context.tsx'

export function TextField({ label }: { label: string }) {
  // `Field` 推断它应该具有 `string` 类型的 `value`
  const field = useFieldContext<string>()
  return (
    <label>
      <div>{label}</div>
      <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 }) {
  // `Field` 推断它应该具有 `string` 类型的 `value`
  const field = useFieldContext<string>()
  return (
    <label>
      <div>{label}</div>
      <input
        value={field.state.value}
        onChange={(e) => field.handleChange(e.target.value)}
      />
    </label>
  )
}

然后您可以将此组件注册到您的表单钩子中。

tsx
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: {},
})

并在您的表单中使用它:

tsx
function App() {
  const form = useAppForm({
    defaultValues: {
      firstName: 'John',
      lastName: 'Doe',
    },
  })

  return (
    // 注意使用 `AppField` 而不是 `Field`;`AppField` 提供所需的上下文
    <form.AppField
      name="firstName"
      children={(field) => <field.TextField label="First Name" />}
    />
  )
}
function App() {
  const form = useAppForm({
    defaultValues: {
      firstName: 'John',
      lastName: 'Doe',
    },
  })

  return (
    // 注意使用 `AppField` 而不是 `Field`;`AppField` 提供所需的上下文
    <form.AppField
      name="firstName"
      children={(field) => <field.TextField label="First Name" />}
    />
  )
}

这不仅允许您重用共享组件的 UI,还保留了您从 TanStack Form 期望的类型安全:拼错 name 会得到 TypeScript 错误。

关于性能的说明

虽然上下文是 React 生态系统中的一个有价值的工具,但许多用户适当地担心通过上下文提供响应式值会导致不必要的重新渲染。

对这个性能问题不熟悉?Mark Erikson 的博客文章解释了为什么 Redux 解决了许多这些问题是一个很好的起点。

虽然这是一个值得提出的好问题,但对于 TanStack Form 来说这不是问题;通过上下文提供的值本身不是响应式的,而是具有响应式属性的静态类实例(使用 TanStack Store 作为我们的信号实现来驱动展示)。

预绑定表单组件

虽然 form.AppField 解决了字段样板代码和可重用性的许多问题,但它没有解决_表单_样板代码和可重用性的问题。

特别是,能够共享 form.Subscribe 的实例,比如用于响应式表单提交按钮,是一个常见的用例。

tsx
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>
      // 注意 `AppForm` 组件包装器;`AppForm` 提供所需的
      上下文
      <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>
      // 注意 `AppForm` 组件包装器;`AppForm` 提供所需的
      上下文
      <form.SubscribeButton label="Submit" />
    </form.AppForm>
  )
}

将大型表单分解为更小的部分

有时表单会变得非常大;有时就是这样。虽然 TanStack Form 很好地支持大型表单,但处理数百或数千行代码的长文件从来都不是有趣的事情。

为了解决这个问题,我们支持使用 withForm 高阶组件将表单分解为更小的部分。

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

const ChildForm = withForm({
  // 这些值仅用于类型检查,在运行时不使用
  // 这允许您从 `formOptions` 中使用 `...formOpts` 而无需重新声明选项
  defaultValues: {
    firstName: 'John',
    lastName: 'Doe',
  },
  // 可选,但除了 `form` 之外还向 `render` 函数添加属性
  props: {
    // 这些属性也被设置为 `render` 函数的默认值
    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({
  // 这些值仅用于类型检查,在运行时不使用
  // 这允许您从 `formOptions` 中使用 `...formOpts` 而无需重新声明选项
  defaultValues: {
    firstName: 'John',
    lastName: 'Doe',
  },
  // 可选,但除了 `form` 之外还向 `render` 函数添加属性
  props: {
    // 这些属性也被设置为 `render` 函数的默认值
    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'} />
}

withForm 常见问题

为什么使用高阶组件而不是钩子?

虽然钩子是 React 的未来,但高阶组件仍然是组合的强大工具。特别是,withForm 的 API 使我们能够拥有强类型安全,而无需用户传递泛型。

为什么我在 render 中使用钩子时会收到 ESLint 错误?

ESLint 在函数的顶层查找钩子,根据您定义它的方式,render 可能不被识别为顶层组件。

tsx
// 这将导致钩子使用的 ESLint 错误
const ChildForm = withForm({
  // ...
  render: ({ form, title }) => {
    // ...
  },
})
// 这将导致钩子使用的 ESLint 错误
const ChildForm = withForm({
  // ...
  render: ({ form, title }) => {
    // ...
  },
})
tsx
// 这样工作正常
const ChildForm = withForm({
  // ...
  render: function Render({ form, title }) {
    // ...
  },
})
// 这样工作正常
const ChildForm = withForm({
  // ...
  render: function Render({ form, title }) {
    // ...
  },
})

树摇表单和字段组件

虽然上面的示例非常适合入门,但对于某些可能有数百个表单和字段组件的用例来说,它们并不理想。 特别是,您可能不希望在使用表单钩子的每个文件的包中包含所有表单和字段组件。

为了解决这个问题,您可以将 createFormHook TanStack API 与 React lazySuspense 组件混合使用:

typescript
// 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()
tsx
// 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>
      <div>{label}</div>
      <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>
      <div>{label}</div>
      <input
        value={field.state.value}
        onChange={(e) => field.handleChange(e.target.value)}
      />
    </label>
  )
}
tsx
// 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: {},
})
tsx
// 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>
  )
}

这将在加载 TextField 组件时显示 Suspense 回退,然后在加载完成后渲染表单。

将所有内容整合在一起

现在我们已经介绍了创建自定义表单钩子的基础知识,让我们在一个示例中将所有内���整合在一起。

tsx
// /src/hooks/form.ts,在整个应用程序中使用
const { fieldContext, useFieldContext, formContext, useFormContext } =
  createFormHookContexts()

function TextField({ label }: { label: string }) {
  const field = useFieldContext<string>()
  return (
    <label>
      <div>{label}</div>
      <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,在 `people` 功能中使用
const formOpts = formOptions({
  defaultValues: {
    firstName: 'John',
    lastName: 'Doe',
  },
})

// /src/features/people/nested-form.ts,在 `people` 页面中使用
const ChildForm = withForm({
  ...formOpts,
  // 可选,但在 `form` 之外向 `render` 函数添加属性
  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,在整个应用程序中使用
const { fieldContext, useFieldContext, formContext, useFormContext } =
  createFormHookContexts()

function TextField({ label }: { label: string }) {
  const field = useFieldContext<string>()
  return (
    <label>
      <div>{label}</div>
      <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,在 `people` 功能中使用
const formOpts = formOptions({
  defaultValues: {
    firstName: 'John',
    lastName: 'Doe',
  },
})

// /src/features/people/nested-form.ts,在 `people` 页面中使用
const ChildForm = withForm({
  ...formOpts,
  // 可选,但在 `form` 之外向 `render` 函数添加属性
  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 使用指导

以下是帮助您决定应该使用哪些 API 的图表: