表单是很多WEB应用程序的基本部分,本文我们学习使用第三方库创建React表单。我们使用React Hook Forms来管理表单状态,使用Zod来验证数据。
创建表单
我们来创建表单,先将之前在main.tsx注释的 bootstrap.css重新启用,这样我们就可以使用现代的样式。
import "bootstrap/dist/css/bootstrap.css";
在commponents文件夹下新建Form.tsx文件,键入 rafce 快捷键生成代码块,再添加相应代码。有个小提示,可以使用 “div.mb-3>label.form-label+imput[type=number].form-control” 这样的快捷代码生成相应HTML代码。
import React from "react";
const Form = () => {
return (
<form>
<div className="mb-3">
<label htmlFor="name" className="form-label">
Name
</label>
<input id="name" type="text" className="form-control" />
</div>
<div className="mb-3">
<label htmlFor="age" className="form-label">
Age
</label>
<input id="age" type="number" className="form-control" />
</div>
<button className="btn btn-primary" type="submit">
Submit
</button>
</form>
);
};
export default Form;
再在src目录下,新建index.css,该css文件用于全局样式设置。
body {
padding: 20px;
}
main.tsx中导入index.css。
... import "bootstrap/dist/css/bootstrap.css"; import "./index.css"; ...
App.tsx引入刚建的Form组件。
import Form from "./components/Form";
function App() {
return (
<div>
<Form />
</div>
);
}
export default App;
保存,查看效果。

处理提交事件
我们来处理下Form的onSubmit事件。注意我们首先要调用event的preventDefault方法,默认情况下,Form的submit将提交给服务器,如果不加这一句,将导致页面全部重载。当然实际生产环境确实需要提交给服务处理,这里我们阻止这个默认行为。参数event的类型是FormEvent,鼠标移到onSubmit事件上可以得到提示。
...
const Form = () => {
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
console.log("Submitted.");
};
return (
<form onSubmit={handleSubmit}>
...
访问输入字段
我们已学习了React中useState的Hook,还有另外的Hook叫useRef,它可以引用Dom元素,这一节我们使用它来获取Form提交时的字段值。
在Form组件中,我们定义了两个useRef,分别为nameRef和ageRef,由于Typescript的严格要求,我们要明确定义类型为HTMLInputElement。在html的input元素中,我们指定属性 ref={nameRef} 类似这样,与实际DOM做好关联。在submit事件处理函数中,先判断引用的Dom是否存在,然后获取value并赋给person对象,再打印出person。
...
const Form = () => {
const nameRef = useRef<HTMLInputElement>(null);
const ageRef = useRef<HTMLInputElement>(null);
const person = {
name: "",
age: 0,
};
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
if (nameRef.current !== null) person.name = nameRef.current.value;
if (ageRef.current !== null) person.age = parseInt(ageRef.current.value);
console.log(person);
};
return (
<form onSubmit={handleSubmit}>
...
<input ref={nameRef} id="name" type="text" className="form-control" />
...
<input ref={ageRef} id="age" type="number" className="form-control" />
...
效果如下:

你可能会注意到,每次我们定义一个useRef时,会初始化成null。如果去掉null,React会报错。useRef要么是null,要么就是一个真实的节点,如果在屏幕上去掉该Dom节点,React会自动将该useRef设回null。这可能是React的一个设计问题,按理这个null应该由React自己负责,用户初始化每个useRef都要明确初始化成null有点丑。但不管如何,目前我们就得如此。
受控组件
可以不使用useRef而是使用之前介绍过的useState来获取Form的字段。
下面实现中,我们创建person的StateHook,然后在input的 onChange事件中处理更新person的信息。在每个HTML元素中都有一个属性value用于维持元素本身的状态,为了保持React与元素本身的同步,我们也设置了value属性受控于person,这样组件就完成由React控制了。
...
const Form = () => {
const [person, setPerson] = useState({
name: "",
age: "",
});
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
console.log(person);
};
return (
...
<input
value={person.name}
onChange={(event) =>
setPerson({ ...person, name: event.target.value })
}
...
/>
...
<input
value={person.age}
onChange={(event) =>
setPerson({ ...person, age: parseInt(event.target.value) })
}
...
这种方法在input每次输入或删除字符时都会重新渲染组件,有人会说这样太慢了,就应该使用useRef。其实这在绝大多数场景不会有问题,除非网页很复杂,严重影响性能时才需要调整。
记住编程界老话:
过早优化是万恶之源!
使用 React Hook Form 管理表单
复杂的网页下,使用useState会很花时间且容易出错,因为需要为大量的Dom元素都设置对应的State Hook,可以借助React Hook Form减轻工作量并减少错误。
安装react-hook-form:
npm i react-hook-form@7.43
修改Form.tsx,我们看到,代码大大减少,但效果是一样的。
顶部导入useForm,调用useForm(),它的结构如图所示。

解构 register及handleSubmit,然后在input中注册name和age,只需使用{…register(‘name’)},不必再处理onChange事件及value值。再在form中使用handleSubmit即可。注意handleSubmit中的参数data是Form中的各字段数据。
import { FieldValues, useForm } from "react-hook-form";
const Form = () => {
const { register, handleSubmit } = useForm();
const onSubmit = (data: FieldValues) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
...
<input
{...register("name")}
...
/>
...
<input
{...register("age")}
...
应用数据验证
Form的数据需要验证,我们来实现下,假设我们要求name的字符数要大于等于3个。
在使用register方法时,我们可以传递普通的HTML属性,比如input的required,minLength等。{ required: true, minLength: 3 }
接着我们要在数据验证不通过时提示用户,解构useForm的formState,formState里有errors属性,这里我们嵌套解构它。
查看errors的结构,我们能查到它的结构里有errors.name.type类似的,通过比较type,我们给出不同的提示信息,如下。
{errors.name?.type === “required” && …}
为防止name不存在而出现运行错误,我们在name?后加了问号,表示如果没有name就忽略。
为了使提示信息醒目,使用了bootstrap的 text-danger样式。
最后,为了更安全地控制Form结构,也为了在编辑器中有更好的提示,我们添加一个interface名为FormData,明确指示字段的类型。
...
interface FormData {
name: string;
age: number;
}
const Form = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>();
return (
...
<input
{...register("name", { required: true, minLength: 3 })}
id="name"
...
/>
{errors.name?.type === "required" && (
<p className="text-danger">The name field is required.</p>
)}
{errors.name?.type === "minLength" && (
<p className="text-danger">The name must be at least 3 characters.</p>
)}
...
Zod 基于模式的验证
当表单复杂时,会有大量的验证,实现是非常浪费时间的,于是出现了一些基于模式的验证库,比如Joi, Yup,以及Zod。本节就介绍Zod,当然Zod内容很丰富,不能全部覆盖,详细了解还需查看Zod的文档。
安装:
npm i zod@3.20.6
修改Form.tsx组件。导入 z,再调用z.object方法,设置schema。schema定义Form的数据结构以及规则限制,如min(3),更多的要查文档。去掉我们之前定义的interface FormData,它与我们定义的schema类似有点重复了,可以调用z.infer生成。typescript中type类型与 interface类似。
import { z } from "zod";
const schema = z.object({
name: z.string().min(3),
age: z.number().min(18),
});
type FormData = z.infer<typeof schema>;
const Form = () => {
...
现在我们要将zod与Hook Form融合,需要先安装库:
npm i @hookform/resolvers@2.9.11
导入 zodResolver,在useForm调用时添加参数对象 { resolver: zodResolver(schema) },每个需要验证的input的数据验证规则也不必分散在各处设置,只需简单使用{ …register(‘name’) },所有规则都放在schema处。age字段需要转换化数字,所以要添加valueAsNumber, {…register(“age”, { valueAsNumber: true })}
各个input的提示信息也不必分散,调用类似 {errors.name && <p className=”text-danger”>{errors.name.message}</p>} 即可。
schema添加了规则后,会有一些默认的错误提示信息,如果想自定义提示信息,只需在schema的字段调用方法时添加 键为 message的对象,比如 { message: “Age must be at least 18.” }等。
import { FieldValues, useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
const schema = z.object({
name: z.string().min(3, { message: "Name must be at least 3 characters." }),
age: z
.number({ invalid_type_error: "Age field is required." })
.min(18, { message: "Age must be at least 18." }),
});
type FormData = z.infer<typeof schema>;
const Form = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({ resolver: zodResolver(schema) });
...
return (
<form onSubmit={handleSubmit(onSubmit)}>
...
<input
{...register("name")}
...
/>
{errors.name && <p className="text-danger">{errors.name.message}</p>}
</div>
...
<input
{...register("age", { valueAsNumber: true })}
...
{errors.age && <p className="text-danger">{errors.age.message}</p>}
...
zod的用法远不止如此,可以访问 https://zod.dev 查看详细文档。
禁用提交按钮
我们希望在数据无效时禁用提交按钮。
formState有一个属性 isValid,我们解构出来使用即可实现数据无效禁用提交按钮。
...
const Form = () => {
const {
...
formState: { errors, isValid },
}
...
<button disabled={!isValid} ...
项目:费用追踪器
来实现一个简单的费用追踪器。
效果如图:
有添加费用的界面,添加时校验数据的有效性。完成添加后,可以在下面的列表中看到清单,清单可以分类筛选、汇总金额,也可删除某条费用。


该项目涵盖了到目前为止我们学习到的React所有知识,下面就来逐步实现。
解决步骤一:创建费用列表
尽管这是一个小项目,我们也需要将它单独放置,在实际项目中也该如此。
在 src 目录下新建 expense-tracker文件夹,再在其下创建components文件夹,接着新建组件ExpenseList.tsx。
ExpenseList组件中,我们构建表格 table,className为”table table-bordered”。表头部分<thead>,我们在<tr><th>上设置相应的标题。
表体部分<tbody>,需要动态生成。它接收父组件的数据进行渲染。在ExpenseList组件中定义Props,其中expenses为费用清单对象Expense的列表,同时定义interface Expense,指定Expense的数据结构。<tbody>部分的<tr>,用了expenses的map方法,将expenses清单详细渲染出来。表体最后一列,使用了一个button,用于删除对应的费用行。onClick用了回调函数onDelete,由父组件来实现。
表脚部分<tfoot>,作用是汇总费用,使用了Javascript的reduce方法,并用toFixed保留小数字2位。
另外,在费用列表为空时,不应该显示表格。我们判断expenses.length是否为0,如果是将返回空,不再渲染表格。
import React from "react";
interface Expense {
id: number;
description: string;
amount: number;
category: string;
}
interface Props {
expenses: Expense[];
onDelete: (id: number) => void;
}
const ExpenseList = ({ expenses, onDelete }: Props) => {
if (expenses.length === 0) return null;
return (
<table className="table table-bordered">
<thead>
<tr>
<th>Description</th>
<th>Amount</th>
<th>Category</th>
<th></th>
</tr>
</thead>
<tbody>
{expenses.map((expense) => (
<tr key={expense.id}>
<td>{expense.description}</td>
<td>{expense.amount}</td>
<td>{expense.category}</td>
<td>
<button
className="btn btn-outline-danger"
onClick={() => onDelete(expense.id)}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td>Total</td>
<td>
$
{expenses
.reduce((acc, expense) => expense.amount + acc, 0)
.toFixed(2)}
</td>
<td></td>
<td></td>
</tr>
</tfoot>
</table>
);
};
export default ExpenseList;
接着,我们修改App.tsx,调用ExpenseList组件。
先建立一些伪数据,使用useState设置expenses和setExpenses,随意键入些数据形成expenses列表。
再调用ExpenseList,设置expenses、onDelete属性。
import { useState } from "react";
import ExpenseList from "./expense-tracker/components/ExpenseList";
function App() {
const [expenses, setExpenses] = useState([
{ id: 1, description: "aaa", amount: 10, category: "Utilities" },
{ id: 2, description: "bbb", amount: 10, category: "Utilities" },
{ id: 3, description: "ccc", amount: 10, category: "Utilities" },
{ id: 4, description: "ddd", amount: 10, category: "Utilities" },
]);
return (
<div>
<ExpenseList
expenses={expenses}
onDelete={(id) => setExpenses(expenses.filter((e) => e.id !== id))}
/>
</div>
);
}
export default App;
目前的效果如下:

解决步骤二:创建费用过滤器
接着我们创建过滤器组件,新组件文件名为ExpenseFilter.tsx。
首先创建下拉选择框 <select>,硬编码一些选项<option>,注意All categories的value设置为空。
我们希望选择某个选项时传递给父组件处理并重新渲染组件,于是创建interface Props,设置onSelectCategory。在<select>的 onChange事件里回调onSelectCategory并传递选择的值。
interface Props {
onSelectCategory: (category: string) => void;
}
const ExpenseFilter = ({ onSelectCategory }: Props) => {
return (
<select
className="form-select"
onChange={(event) => onSelectCategory(event.target.value)}
>
<option value="">All categories</option>
<option value="Groceries">Groceries</option>
<option value="Utilities">Utilities</option>
<option value="Entertainment">Entertainment</option>
</select>
);
};
export default ExpenseFilter;
然后我们在父组件App.tsx中使用ExpenseFilter组件。
创建StateHook,命为selectedCategory,用于存储选择的category,初始为空。App组件调用ExpenseFilter组件,并实现onSelectCategory,主要就是设置selecedtCategory。
为达到筛选目的,创建一个visibleExpenses列表,选择category为selectedCategory的expense。再修改之前的ExpenseList组件,属性expenses设置为visibleExpenses。
为了使两个组件之间有美观的间隔,将ExpenseFilter用“mb-3”的<div>包裹。
function App() {
const [selectedCategory, setSelectedCategory] = useState("");
const [expenses, setExpenses] = useState([
...
]);
const visibleExpenses = selectedCategory
? expenses.filter((e) => e.category === selectedCategory)
: expenses;
return (
<div>
<div className="mb-3">
<ExpenseFilter
onSelectCategory={(category) => setSelectedCategory(category)}
/>
</div>
<ExpenseList
expenses={visibleExpenses}
onDelete={(id) => setExpenses(expenses.filter((e) => e.id !== id))}
/>
</div>
);
}
export default App;
至目前,效果如图:

解决步骤三:创建费用表单
表单中也会用到 categories列表,我们已在ExpenseFilter中硬编码了,自然不希望再写一遍,我们把提取到App.tsx中,这样各组件都可使用它。
在App.tsx中,我们在import语句后,设置常量categories列表,并export。
... export const categories = ["Groceries", "Utilities", "Entertainment"]; ...
再到之前的ExpenseFilter.tsx中,导入categories,在<select>的<option>中,我们通过categories的map方法动态生成各category选项。
...
import { categories } from "../../App";
...
<option value="">All categories</option>
{categories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
...
然后开始创建ExpenseForm.tsx组件。
我们用类似 “.mb-3>label.form-label+input[type=number].form-control”+TAB键的快捷方式生成各Form的字段,再设置<input>的id与<label>的 htmlForm对应,以及显示信息。
对于category,我们使用了跟ExpenseFilter一样的categories.map方法动态生成。
最后添加一个按钮<button>。
形成的代码如下:
import React from "react";
import { categories } from "../../App";
const ExpenseForm = () => {
return (
<form>
<div className="mb-3">
<label htmlFor="description" className="form-label">
Description
</label>
<input id="description" type="text" className="form-control" />
</div>
<div className="mb-3">
<label htmlFor="amount" className="form-label">
Amount
</label>
<input id="amount" type="number" className="form-control" />
</div>
<div className="mb-3">
<label htmlFor="category" className="form-label">
Category
</label>
<select id="category" className="form-select">
<option value="">All categories</option>
{categories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</div>
<button className="btn btn-primary">Submit</button>
</form>
);
};
export default ExpenseForm;
回到App.tsx调用。
...
return (
<div>
<div className="mb-5">
<ExpenseForm />
</div>
...
目前的效果如下。

解决步骤四:集成React Hook和Zod
之前ExpenseFilter和ExpenseForm都导入了App中定义的categories,为了避免依赖顺序错误,在expense-tracker目录下新建单独的模块categories.ts,注意这不是组件,扩展名建议为ts而不是tsx。将categories移入该文件,我们在categories后加了as const表示该列表不能再添加。categories已移至新模块,注意修改ExpenseFilter和ExpenseForm的导入语句的路径。
const categories = ["Groceries", "Utilities", "Entertainment"] as const; export default categories;
导入zod的z,调用z.object创建数据结构schema,我们有desctription, amout以及category,设置数据类型,规则以及错误提示友好信息。number和string方法之前已讲过,没什么新鲜了,而category使用的是z.enum方法,参数的列表需要只读和常量,这里传递的是categories,这个已移至categories.ts中了,而且已加上了 as const符合调用的要求。
enum的错误友好提示设置也很特别,各种库的调用方法也不尽相同,我们只需知道如何使用即可。
接着定义ExpenseFormData,由定义的schema转换。
目前的ExpenseForm.tsx添加的部分代码如下:
import { z } from "zod";
...
const schema = z.object({
desctription: z
.string()
.min(3, { message: "Description should be at least 3 charaters." })
.max(50),
amount: z
.number({ invalid_type_error: "Amount is required." })
.min(0.01)
.max(100_000),
category: z.enum(categories, {
errorMap: () => ({ message: "Category is required." }),
}),
});
type ExpenseFormData = z.infer<typeof schema>;
...
接着,我们要将useForm与zod整合。
导入useForm和zodResolver,用useForm解构出register, handleSubmit及formState的errors,再在具体的表单字段里用register方法注册,比如 {…register(‘amount’, {valueAsNumber:true})} 。
再在每个表单字段下添加错误信息提示展示,这些之前都已介绍过。
...
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
...
const ExpenseForm = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ExpenseFormData>({ resolver: zodResolver(schema) });
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<div className="mb-3">
...
<input
{...register("desctription")}
id="description"
...
/>
{errors.desctription && (
<p className="text-danger">{errors.desctription.message}</p>
)}
</div>
...
{...register("amount", { valueAsNumber: true })}
id="amount"
...
{errors.amount && (
<p className="text-danger">{errors.amount.message}</p>
)}
</div>
...
<select {...register("category")} id="category" className="form-select">
...
{errors.category && (
<p className="text-danger">{errors.category.message}</p>
)}
...
解决步骤五:添加费用条目
ExpenseForm.tsx组件添加Props,定义onSubmit类型。解构useForm的reset方法,组件在<form>的onSubmit事件中做两件事情,一件是调用父级执行的onSubmit,再调用reset()清空表单。
...
interface Props {
onSubmit: (data: ExpenseFormData) => void;
}
...
const ExpenseForm = ({ onSubmit }: Props) => {
const {
...
reset,
...
} = useForm<ExpenseFormData>({ resolver: zodResolver(schema) });
return (
<form
onSubmit={handleSubmit((data) => {
onSubmit(data);
reset();
})}
...
再到App.tsx。
调用ExpenseForm组件时提供onSubmit实现将expense添加到expenses中,expenses中有一个id,这里就简单使用expenses.length+1,实际场景中可能需要服务器自动生成,这里不必考虑。
...
return (
<div>
<div className="mb-5">
<ExpenseForm
onSubmit={(expense) =>
setExpenses([...expenses, { ...expense, id: expenses.length + 1 }])
}
/>
</div>
...
小结
本文我们学习了如何创建React表单,介绍使用第三方库React Hook Forms来管理表单状态,使用Zod来验证数据,并详细讲解了一个项目实现。
下一篇介绍后端的连接。
