← 返回蜂巢洞察

现代React表单架构指南:TanStack Form、Zod与Shadcn的组合使用

在React中构建可用于生产环境的表单,往往会带来诸多麻烦。这是前端开发中让大多数开发者感到不适的部分之一。 通常,你会先使用`useState`钩子来创建一个简单的受控表单。但当表单的结构变得越来越复杂时,你会发现,在输入框中输入一个字符就会导致整个组件树重新渲染。这样一来,应用程序的性能会大幅下降,用户体验也会变得非常糟糕。 你或许还会尝试通过改用`useRef`钩子来创建非受控表单,以此缓解这种重新渲染的问题。然而,这样做会带来严重的可扩展性隐患。随着表单规模的扩大,维护代码就会变成一场噩梦。 虽然快速编写代码可能能让你得到一个可以运行的演示版本,但真正的工程实践才是为了让应用程序能够成

在React中构建可用于生产环境的表单,往往会带来诸多麻烦。这是前端开发中让大多数开发者感到不适的部分之一。 通常,你会先使用`useState`钩子来创建一个简单的受控表单。但当表单的结构变得越来越复杂时,你会发现,在输入框中输入一个字符就会导致整个组件树重新渲染。这样一来,应用程序的性能会大幅下降,用户体验也会变得非常糟糕。 你或许还会尝试通过改用`useRef`钩子来创建非受控表单,以此缓解这种重新渲染的问题。然而,这样做会带来严重的可扩展性隐患。随着表单规模的扩大,维护代码就会变成一场噩梦。 虽然快速编写代码可能能让你得到一个可以运行的演示版本,但真正的工程实践才是为了让应用程序能够成功应用于生产环境。为了解决这些性能和可扩展性问题,我们有必要重新思考输入组件的工作原理。 在本文中,我们将构建一种适用于生产环境的表单架构。我们会使用`TanStack Form`作为无头状态机来解决性能和可扩展性方面的问题;利用`Zod`来进行严格的验证;而`ShadCN UI`则能帮助我们创建出既美观又易于使用的组件。 如果你属于视觉型学习者,我在我的YouTube频道tapaScript上录制了一节专门讲解这种架构的完整视频课程。你可以直接在这里观看:

目录

  1. 传统表单存在的问题

  2. 介绍TanStack Form

  3. 细粒度的反应式编程机制

  4. 使用Zod进行验证

  5. 结合ShadCN UI的无头TanStack Form

  6. 如何使用TanStack Form处理嵌套的动态数组

  7. TanStack Form的细粒度反应式机制:`useSelector`与`form.subscribe`的区别

  8. TanStack Form与React Hook Form的对比

  9. 结论与重要资源

  10. 如果你已经读到了这里……

传统表单形式存在的问题

当开发者意识到`useState`允许他们利用React的状态来控制表单的逻辑时,他们往往会按照以下方式创建表单组件:

import { useState } from "react";

export function StandardRegistrationForm() {
 
  const [formData, setFormData] = useState({
    firstName: "",
    lastName: "",
    email: "",
    password: "",
  });

  console.log("表单已重新渲染!当前状态:", formData.firstName);

  const handleChange = (e: React.ChangeEvent) => {
    setFormData((prev) => ({
      ...prev,
      [e.target.name]: e.target.value,
    }));
  };

  return (
    

问题所在

标准控制的表单形式

名字
姓氏
电子邮件
); }

这个组件使用了集中式的`useState`对象,而这正是大多数开发者被教导用来构建表单的标准方法。一旦任何输入字段的值发生变化,状态就会立即得到更新。

在 React 中,每当您更新状态时,组件都会重新渲染以显示当前的状态值。这就会导致性能问题,而您需要解决这个问题。

下图清楚地展示了这样一个性能问题:每当用户在输入框中输入一个字符时,相关表单组件就会重新渲染。

组件的重新渲染

不受控制的“ref”组件的解决方法

鉴于我们在使用受控表单组件时遇到的这些问题,开发人员找到了一种解决办法。如果我们不使用状态,而是使用“ref”,那么当“ref”的当前值发生变化时,组件就不会重新渲染。这个方法听起来似乎很不错,但事实上它会带来完全不同的架构问题。

以下是使用“ref”而不是状态来管理相同表单的示例:

import { useRef } from "react";

export function RefRegistrationForm() {
 
  const firstNameRef = useRef(null);
  const lastNameRef = useRef(null);
  const emailRef = useRef(null);

  console.log("表单已渲染(注意,在输入内容时控制台不会输出任何信息)");

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    const formData = {
      firstName: firstNameRef.current?.value,
      lastName: lastNameRef.current?.value,
      email: emailRef.current?.value,
    };

    console.log("提交的数据:", formData);

  };

  return (
    

“ref”组件的解决方法

这种方法速度很快,但扩展性很差。

); }

让我们来谈谈这种方法存在的问题:

  • “引用对象”的问题:我们为每一个输入字段都创建了一个单独的引用对象。想象一下,如果这个表单包含20个以上的字段,那你将不得不管理所有这些引用对象!

  • 我们需要手动提取数据,然后再将这些数据重新组合成完整的对象。这里并没有像`setState()`这样的机制可供使用。

  • 如何实现实时验证呢?由于在用户输入内容时,相关的字段或组件并不会重新渲染,因此如果不引入新的状态变量,要想在输入框下方显示动态错误信息就会变得非常困难;而引入新的状态变量又会违背使用引用对象的初衷。

为什么不直接使用原生的HTML表单呢?

没错!我们也不应该忽略这个选项。既然有这样的可能性,为什么还要使用第三方库呢?我们不能直接使用原生的HTML表单,在用户提交表单时再通过`FormData`来获取数据吗?

如果只是构建简单的“联系我们”或“登录表单”,那么原生的HTML表单确实是最适合的工具。它们结构简单,不需要了解任何第三方库的知识,而且效率也非常高。

但对于那些复杂、用于生产环境的Web应用程序来说,原生方法很快就会遇到瓶颈:

  1. 复杂的数据结构:`FormData`是一个扁平化的对象。如果你需要处理深度嵌套的对象或动态数组,那么从扁平化的`FormData`对象中手动提取像`speakers[0][firstName]`这样的数据会非常麻烦。

  2. 动态的用户界面:如果你的应用有这样的需求:只有当字段A的值被设置为“是”时,字段B才会显示出来,那么你仍然需要使用客户端的状态来跟踪字段A的值。而`FormData`只在表单提交的那一刻才存在。

  3. 用户体验与实时验证:原生的HTML表单提供的验证功能较为僵化,在不同浏览器中很难实现统一的样式效果。如果你需要进行复杂的跨字段验证(比如确认密码是否一致),或者需要异步进行验证(比如在用户输入用户名时检查该用户名是否已被占用),那么你就需要使用JavaScript了。

当原生表单无法满足需求时,我们就转向React来解决问题。

介绍TanStack Form

TanStack Form是一个专为处理用户输入而设计的无状态框架。它提供了严格的类型检查以及便捷的数据管理功能,同时通过细粒度的反应式机制来确保只有你正在编辑的字段才会被重新渲染,其他部分则保持不变。

让我们在现代的Vite + TypeScript环境中配置它吧。首先,使用以下命令安装TanStack Form:

npm install @tanstack/react-form # 或者使用等效的yarn或pnpm命令

你不需要使用一大堆单独的状态管理钩子,只需要使用`useForm`这个钩子就能启动表单处理机制:

import { useForm } from '@tanstack/react-form';

export function SpeakerForm() {
  const form = useForm({
    defaultValues: {
      firstName: '',
      lastName: '',
      twitterHandle: '',
    },
    onSubmit: async ({ value }) => {
      console.log('表单已提交!', value);
    },
  });

  return (
    

添加新的演讲者

{/* 我们将在这里构建表单字段 */}
); }

请注意`defaultValues`这个对象。TanStack Form会自动根据这个对象来推断出整个表单的结构。如果你后来尝试引用一个无效的字段名称,TypeScript会立即抛出错误。

细粒度的反应性

现在,让我们将表单输入元素与处理引擎连接起来。为此,我们需要使用TanStack的`Field`组件来包裹这些输入元素。这个组件采用了标准的JSX `render prop`模式,从而能够将各个字段的状态直接传递到用户界面中。

<form
  onSubmit={(e) => {
    e.preventDefault();
    e.stopPropagation();
    form handleSubmit();
  }}
  className="space-y-4"
>
  <form.Field name="firstName">
    {(field) => {
      // 证明:只有当“firstName”字段的值发生变化时,这条日志才会被输出!
      console.log("正在渲染‘First Name’字段");
      
      return (
        
); }} </form.Field>

如果你在父组件中添加一条`console.log`语句,同时在`Field`组件内部也添加一条类似的日志语句,你就会看到神奇的效果:当你在“名字”输入框中输入内容时,父组件并不会重新渲染,只有表单的特定字段会被重新绘制。

TanStack Form

状态的变化仅限于那个特定的`Field`组件内部定义的函数范围内。

使用Zod进行验证

只有具备了字段验证功能,表单才真正具有实用性。在当前的开发生态系统中,Zod是业界公认的标准工具。首先,你需要安装Zod以及它的表单适配器。

npm install zod @tanstack/zod-form-adapter # 或者使用yarn、pnpm等命令进行安装

然后,将Zod适配器集成到你的表单处理引擎中:

import { zodValidator } from '@tanstack/zod-form-adapter';
import { z } from 'zod';

// 在你的组件内部...
const form = useForm({
  defaultValues: { firstName: '', lastName: '' },
  validatorAdapter: zodValidator(),
  onSubmit: async ({ value }) => { /* ... */ },
});

接下来,我们将更新我们的表单字段,使其使用Zod规则进行验证。在这里,我们规定名字必须至少由2个字符组成。同时,通过检查`field.state.meta.isTouched`的值,我们可以确保只有在用户对输入内容进行了操作后,才会显示错误提示。



  {(field) => (
    
) : null}
> )}

下图展示了当名字字段的值发生变化且验证失败时,错误信息会如何显示在该字段下方。

表单验证

使用ShadCN UI的无头TanStack表单

TanStack表单是无头的,这意味着你可以将其无缝地集成到任何设计系统中。以目前流行的设计系统之一——ShadCN UI为例来说吧。

如果你正在使用基于Vite-TypeScript的React项目,那么只需以下几个简单的步骤就能将ShadCN UI集成到你的项目中:

首先,打开`tsconfig.json`文件,并添加以下编译器选项:

"compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": [
        "./src/*"
      ]
    }
  }

然后打开`vite.config.json`文件,为`@`别名添加相应的映射配置:

import react from '@vitejs/plugin-react'
import path from "path"
import { defineConfig } from 'vite'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
})

接下来,安装shadcn及其相关的组件,比如标签、输入框和按钮:

npx shadcn@latest add input label button

现在,你就可以将这些ShadCN组件导入到你的表单组件中,从而替换原有的HTML标签、输入框和按钮了。

import { Input } from 「@components/ui/input」; import { Label } from 「@components/ui/label」; {(field) => (
field.handleChange(e.target.value)} className="bg-neutral-950 border-neutral-700 focus-visible:ring-emerald-500" /> {field.state.meta.isTouched && field.state.meta.errors.length ? ( {field.state.meta.errors.map((error) => error.message)} : null}
})

使用 TanStack Form 处理嵌套的动态数组

假设我们正在为一次聚会创建一个演讲者信息表单。如果我们要在聚会上安排多位演讲者,那么我们就需要一种数据结构来存储这些演讲者的详细信息,这时我们就需要使用一个对象数组。

在传统的 React 中,当需要管理具有复杂状态的对象数组时,很容易导致代码结构变得混乱不堪。而 TanStack Form 将数组视为普通的表单字段来处理。我们只需要将 `defaultValues` 设置为一个数组即可:

defaultValues: {
  speakers: [ { firstName: '', lastName: '' } ],
},

接下来,将该字段的模式设置为 array,然后对其进行遍历和处理:

<form.Field name="speakers" mode="array">
  {(field) => (
    <div className="space-y-6">
      {field.state.value.map((_, index) => (
        
> {field.state.value.length > 1 && ( )}
)} >
))} {/* 删除按钮 */} <Button type="button" variant="outline" onClick={() => field.removeValue(index)} > - 删除
)} </form.Field>

要添加数据,我们调用 field.pushValue();要删除数据,则调用 field.removeValue(index)。在新增行时,之前的数组字段内容保持不变,这种处理方式性能非常高。

对象数组

上图展示了这种多演讲者信息表单及其提交后的显示效果。

TanStack Form 的细粒度反应性:useSelectorform.Subscribe

有时,你需要在输入框之外访问表单的状态。例如,当用户更改输入字段的值时,你希望立即显示“未保存的更改!”提示信息。

需要注意的是,这里的提示信息虽然位于表单之外,但它完全取决于表单字段的值。

未保存的数据

TanStack为这类需求提供了两种不同的工具,知道何时使用哪种工具是掌握这种架构的关键。

使用useSelector()

如果状态的变化会影响到页面的整体布局(比如像上面那样显示一个“未保存的更改”提示栏),那么你就需要让父组件重新渲染。这时我们可以使用独立的useSelector()钩子来实现这一目标:

import { useForm, useSelector } from "@tanstack/react-form";

export function ReactivityDemo() {
  const form = useForm({ defaultValues: { bio: "" } });

  // 这里只选取我们希望父组件跟踪的那部分状态
  const isFormDirty = useSelector(form.store, (state) => state.isDirty);

  return (
    
)} {/* 表单内容放在这里 */}
); }

使用form.subscribe()

如果状态的变化只影响某个特定的交互行为,比如在表单正在保存时禁用提交按钮,那么我们就不需要让整个表单重新渲染。在这种情况下,我们可以将相关代码放在内部:

>
  {([canSubmit, isSubmitting]) => (
    
  )}

这样,只有提交按钮会重新渲染,组件的其他部分则保持不变。

TanStack Form与React Hook Form的对比

如果你已经从事React应用程序的开发有一段时间了,你可能会想:“为什么不直接使用React Hook Form呢?”

React Hook Form确实是一个非常优秀的库,多年来一直被视为行业标准。它通过利用不受控制的组件和useRef来解决性能问题,能够完美地实现状态变化的隔离处理,并且也能很好地处理验证逻辑。

但是,当应用程序的复杂性增加时,TanStack Form架构相比React Hook Form具有三个明显的优势:

动态数组

如果你曾经使用React Hook Form构建过需要动态嵌套数组的复杂表单(比如在上面的例子中添加多个扬声器),那么你很可能需要像useFieldArray这样的钩子。管理复杂的useFieldArray实现通常会涉及大量的样板代码、精确的索引追踪,以及为确保TypeScript类型安全而进行的深入处理。

TanStack Form消除了这些麻烦。它将数组视为与字符串输入相同的类型进行处理。该表单实际上是一个集中式的状态机:你只需告诉某个字段其mode=array,就能立即使用pushValue()removeValue()方法,并且还能享受到深度嵌套结构下的类型安全保障。

深度嵌套组件

React Hook Form依靠非受控输入来提升性能。虽然这种方式运行速度很快,但在深度嵌套的组件结构中传递引用却会变得很复杂,尤其是当与复杂的UI库集成时。

TanStack Form为我们提供了受控组件的严谨且可预测的架构,并通过细粒度的反应式机制确保只有真正发生变化的UI部分才会被重新渲染。

生态系统的协同性

如果你正在使用TanStack Query进行数据获取,同时使用TanStack Router进行导航,那么TanStack Form与你所使用的其他技术有着完全相同的思维模式和架构体系。它能够无缝融入整个生态系统,为你的应用程序架构提供统一的开发体验。

结论与重要资源

通过结合使用TanStack Form、Zod以及ShadCN UI,我们构建了一种类型严格、具有反应式特性的、可用于生产环境的表单架构。这种架构能够处理从基本文本输入到复杂嵌套数组等各种类型的数据,而且丝毫不会影响性能。

你可以在我的GitHub仓库中获取完整的入门代码和最终项目源码:

如果你觉得这些内容有帮助,那么以下这两段深入的视频教程也会对你很有帮助:

如果你已经读到了这里……

非常感谢你!

我很高兴地宣布,我刚刚开始制作了一门全栈免费课程,旨在帮助开发者从基础编程知识逐步掌握适用于生产环境的开发思维模式。如果你能抽时间观看并参与其中,我会感到非常荣幸。

期待在下一篇文章中与大家见面。在此之前,祝大家一切安好,继续努力学习吧!

相关文章

技术实践

如何在Flutter中测试人工智能相关功能?[完整指南]

你花了两周时间开发这个AI助手。流式聊天功能设计得很美观,系统提示语也很简洁,安全过滤机制也配置好了。 你向团队展示了这个成果,大家都感到非常印象深刻。随后你将应用提交到了App Store,它终于上线了。 然而在上线三天后,有用户反馈称:如果连续快速点击两次发送按钮,就会出现两个永远无法停止旋转的加载图标;还有用户发现,如果在聊天过程中关闭应用程序再重新打开,聊天界面就会崩溃。 你们团队中的某位成员修改了`AIRepository`中的错误信息字符串,但由于测试用例检测的内容有误,所以测试结果仍然显示正常。一位产品经理询问如果Gemini API不可用,这个新功能是否会出问题,但没有人知道答

阅读全文
技术实践

如何使用shadcn/ui构建一个开源的SaaS产品着陆页模板

大多数SaaS产品的登录页面都包含相同的核心组成部分:首页展示区、用户评价、功能介绍、价格信息、常见问题解答以及页脚。而大多数开发者都会在每个项目中从头开始构建这些内容,这种重复性工作其实并不属于真正的软件开发过程。 因此,我开发了一个名为 ChatDeck 的SaaS登录页面模板,并将其开源。该模板基于Next.js 16、React 19、shadcn/ui以及新的 base-nova 样式框架进行开发,同时使用了Tailwind CSS v4和TypeScript。完整的源代码托管在GitHub上,遵循MIT许可证。这个模板的诞生源于我在开发过程中所做的各种决策。 在构建这个模板的过程中

阅读全文
技术实践

如何使用shadcn/ui创建营销着陆页

大多数营销着陆页都会遇到同样的问题:你面对的只是一块空白屏幕,而你需要重新创建那些已经制作过无数次的页面元素。 标题栏、功能展示区、用户评价、价格信息、常见问题解答以及页脚这些内容都是常见的构建模块,但开发人员往往会在每个新项目中花费数小时来重新设计这些部分。 通过本指南,你将学习如何使用Next.js、shadcn/ui、Tailwind CSS以及可复用的shadcn组件来构建现代化的营销着陆页。你无需从零开始创建每一个页面元素,而是可以直接组装出一个可用于实际项目的完整页面,然后根据自己的品牌需求进行定制,从而获得一个适用于各种实际开发场景的基础框架。 目录 先决条件 我们要构建什么?

阅读全文
技术实践

如何使用 shadcn/ui 在 React 中构建一个可重复使用的日期时间选择器

日期和时间选择器这类组件,在设计文件中看起来可能很简洁,但一旦开始实际开发,就会发现它们会消耗大量的资源。你需要一个日历、一个时间选择器,以及一个能够保证这两者同步的状态管理系统,通常还需要范围选择功能以及对应的多语言版本。 本指南将介绍一些现成的选择器组件,你可以直接将这些组件应用到你的React项目中:组合型日期和时间选择器、日期范围选择器以及时间选择器。 所有这些组件都可以作为 Shadcn日期和时间选择器 组件使用,你只需通过一条CLI命令即可安装它们,而无需从头开始开发。 这些组件都是基于Radix和Base UI的基础架构构建的,下面介绍的版本是使用Base UI实现的。此外,这些

阅读全文