一、复杂表单
在第一篇《深入理解 React 受控 / 非受控模式》中,我们掌握了表单数据管理的基础逻辑,但实际开发中,表单往往不是 “单个输入框 + 提交按钮” 的简单形态 —— 比如电商的收货地址表单需要省市区联动,CRM 系统的客户信息表单支持动态添加联系人,注册流程需要分步骤填写信息。这些 “复杂场景” 会暴露基础用法的不足:
-
多字段联动时,手动管理 state 容易出现 “数据不同步”;
-
动态添加字段时,用数组维护状态会遇到 key 冲突、校验遗漏等问题;
-
多步骤表单中,跨步骤数据传递、回退时的状态重置会让逻辑变得混乱。
本文将基于 受控模式(复杂场景下更易保证数据可控性),通过 3 个实战案例,拆解复杂表单的核心解决方案,每个案例均包含 “需求描述 + 完整代码 + 避坑指南”,确保看完就能落地。
二、实战案例 1:多字段联动 —— 从 “单向响应” 到 “双向关联”
1. 典型场景:省市区三级联动
需求描述:用户选择 “省份” 后,“城市” 下拉框加载对应省份的城市列表;选择 “城市” 后,“区县” 下拉框加载对应城市的区县列表;最终需要收集完整的省 / 市 / 区名称及编码,且不允许出现 “选了北京却显示上海区县” 的无效状态。
2. 实现步骤与代码
(1)准备基础数据与状态
首先定义省份、城市、区县的映射数据(实际项目中通常通过接口请求获取),再用 useState 分别管理 “选中值” 和 “下拉列表选项”,避免状态混在一起导致维护困难:
import { useState, useEffect } from 'react';
// 模拟省市区数据(实际项目中替换为接口请求)
const provinceData = [ { code: '110000', name: '北京市' }, { code: '310000', name: '上海市' }, { code: '440000', name: '广东省' }];
const cityData = {
'110000': [{ code: '110100', name: '北京市' }],
'310000': [{ code: '310100', name: '上海市' }],
'440000': [
{ code: '440100', name: '广州市' },
{ code: '440300', name: '深圳市' }
]
};
const districtData = {
'110100': [
{ code: '110101', name: '东城区' },
{ code: '110102', name: '西城区' }
],
'310100': [
{ code: '310101', name: '黄浦区' },
{ code: '310104', name: '徐汇区' }
],
'440100': [
{ code: '440103', name: '荔湾区' },
{ code: '440104', name: '越秀区' }
],
'440300': [
{ code: '440305', name: '南山区' },
{ code: '440306', name: '宝安区' }
]
};
function AddressLinkageForm() {
// 选中值状态:存储省/市/区的编码(便于后续提交给后端)
const [selected, setSelected] = useState({
provinceCode: '',
cityCode: '',
districtCode: ''
});
// 下拉列表选项状态:存储当前可选择的城市、区县列表
const [options, setOptions] = useState({
cities: [],
districts: []
});
// ...后续逻辑
}
(2)实现联动逻辑:useEffect 监听状态变化
联动的核心是 “上级选项变化时,更新下级列表并重置下级选中值”。这里用两个 useEffect 分别监听 “省份变化” 和 “城市变化”,精准控制状态更新:
// 监听省份变化:更新城市列表 + 重置市、区选中值
useEffect(() => {
if (selected.provinceCode) {
// 1. 加载当前省份对应的城市列表
const matchedCities = cityData[selected.provinceCode] || [];
setOptions(prev => ({
...prev,
cities: matchedCities
}));
// 2. 重置市、区的选中值(避免选中无效数据)
setSelected(prev => ({
...prev,
cityCode: '',
districtCode: ''
}));
// 3. 清空区县列表(因为城市已重置,区县自然无数据)
setOptions(prev => ({ ...prev, districts: [] }));
} else {
// 未选省份时,清空所有下级列表和选中值
setOptions({ cities: [], districts: [] });
setSelected({ provinceCode: '', cityCode: '', districtCode: '' });
}
}, [selected.provinceCode]); // 仅依赖省份编码,避免多余渲染
// 监听城市变化:更新区县列表 + 重置区选中值
useEffect(() => {
if (selected.cityCode) {
// 1. 加载当前城市对应的区县列表
const matchedDistricts = districtData[selected.cityCode] || [];
setOptions(prev => ({
...prev,
districts: matchedDistricts
}));
// 2. 重置区的选中值
setSelected(prev => ({ ...prev, districtCode: '' }));
} else {
// 未选城市时,清空区县列表和区选中值
setOptions(prev => ({ ...prev, districts: [] }));
setSelected(prev => ({ ...prev, districtCode: '' }));
}
}, [selected.cityCode]); // 仅依赖城市编码,确保触发时机准确
(3)渲染表单与处理选择事件
定义 handleSelectChange 函数统一处理下拉框选择事件,通过 type 参数区分 “省份 / 城市 / 区县”,避免写三个重复的事件处理器:
// 统一处理下拉框选择事件
const handleSelectChange = (type, code) => {
setSelected(prev => ({
...prev,
[`${type}Code`]: code // 动态设置对应字段的编码(如 type=province 则更新 provinceCode)
}));
};
// 提交时获取完整地址信息(编码 + 名称)
const handleSubmit = () => {
// 根据选中的编码,匹配对应的名称
const selectedProvince = provinceData.find(p => p.code === selected.provinceCode);
const selectedCity = cityData[selected.provinceCode]?.find(c => c.code === selected.cityCode);
const selectedDistrict = districtData[selected.cityCode]?.find(d => d.code === selected.districtCode);
const fullAddress = {
province: selectedProvince?.name || '',
city: selectedCity?.name || '',
district: selectedDistrict?.name || '',
provinceCode: selected.provinceCode,
cityCode: selected.cityCode,
districtCode: selected.districtCode
};
console.log('提交的完整地址:', fullAddress);
alert(`已选择:${fullAddress.province}${fullAddress.city}${fullAddress.district}`);
};
// 渲染表单
return (
<div style={{ padding: '20px', maxWidth: '500px' }}>
<h3>省市区三级联动</h3>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>省份:</label>
<select
value={selected.provinceCode}
onChange={(e) => handleSelectChange('province', e.target.value)}
style={{ width: '100%', padding: '8px', borderRadius: '4px' }}
required
>
<option value="">请选择省份</option>
{provinceData.map(province => (
<option key={province.code} value={province.code}>
{province.name}
</option>
))}
</select>
</div>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>城市:</label>
<select
value={selected.cityCode}
onChange={(e) => handleSelectChange('city', e.target.value)}
style={{ width: '100%', padding: '8px', borderRadius: '4px' }}
required
disabled={!selected.provinceCode} // 未选省份时禁用,提升体验
>
<option value="">请选择城市</option>
{options.cities.map(city => (
<option key={city.code} value={city.code}>
{city.name}
</option>
))}
</select>
</div>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>区县:</label>
<select
value={selected.districtCode}
onChange={(e) => handleSelectChange('district', e.target.value)}
style={{ width: '100%', padding: '8px', borderRadius: '4px' }}
required
disabled={!selected.cityCode} // 未选城市时禁用
>
<option value="">请选择区县</option>
{options.districts.map(district => (
<option key={district.code} value={district.code}>
{district.name}
</option>
))}
</select>
</div>
<button
onClick={handleSubmit}
style={{
padding: '8px 16px',
backgroundColor: '#1890ff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
disabled={!selected.districtCode} // 未选区县时禁用提交
>
确认选择
</button>
</div>
);
3. 联动场景的避坑点
- 依赖数组必须精准:useEffect 的依赖只能是需要监听的状态(如 selected.provinceCode),若写成 [selected],会导致其他字段变化时也触发更新,造成冗余渲染;
- 必须重置无效状态:切换上级选项(如从 “北京” 切到 “广东”)时,一定要重置下级的选中值和列表,否则会出现 “广东 + 北京市 + 东城区” 的无效组合;
- 禁用无效操作:当下级列表未加载时(如未选省份),禁用下级下拉框,避免用户点击无效选项,提升交互体验。
三、实战案例 2:动态表单 —— 支持 “添加 / 删除” 字段
1. 典型场景:动态添加联系人
需求描述:用户点击 “添加联系人” 按钮可新增一行表单(包含姓名、电话、邮箱);每行表单右侧有 “删除” 按钮,点击可删除当前行;提交时需校验所有行的字段不能为空,且收集所有联系人数据。
2. 实现步骤与代码
(1)用数组 state 管理动态字段
动态表单的核心是 “将每个字段组作为数组的一项”,通过数组的 map 渲染所有行,通过 push/filter 实现添加 / 删除:
import { useState } from 'react';
function DynamicContactForm() {
// 动态联系人列表:每个元素是一个联系人对象(id + 字段)
const [contacts, setContacts] = useState([
{ id: 1, name: '', phone: '', email: '' } // 默认显示1行
]);
// 生成唯一id:避免用index作为key(删除中间行会导致DOM复用bug)
const generateUniqueId = () => Date.now() + Math.floor(Math.random() * 1000);
// ...后续逻辑
}
(2)实现 “添加 / 删除” 逻辑
- 添加:在数组末尾新增一个带唯一 id 的空联系人对象;
- 删除:根据 id 过滤掉需要删除的项,返回新数组(不直接修改原数组,符合 React 状态不可变原则):
// 添加联系人
const handleAddContact = () => {
setContacts(prevContacts => [
...prevContacts,
{ id: generateUniqueId(), name: '', phone: '', email: '' }
]);
};
// 删除联系人:根据id删除对应行
const handleDeleteContact = (id) => {
// 至少保留1行,避免表单为空
if (contacts.length <= 1) {
alert('至少保留1个联系人');
return;
}
setContacts(prevContacts => prevContacts.filter(contact => contact.id !== id));
};
(3)统一处理字段输入变化
由于字段是动态的,需要通过 “id + 字段名” 定位到具体要更新的字段,避免修改所有行数据:
// 处理字段输入变化:id定位行,field定位字段
const handleFieldChange = (id, field, value) => {
setContacts(prevContacts =>
prevContacts.map(contact =>
// 匹配到当前行时,更新对应字段;否则保持不变
contact.id === id ? { ...contact, [field]: value } : contact
)
);
};
(4)提交校验与渲染表单
提交前需遍历所有行,校验字段是否为空;渲染时用 map 遍历数组,生成所有表单行:
// 提交表单:校验 + 收集数据
const handleSubmit = (e) => {
e.preventDefault();
// 1. 校验所有字段:不能为空
const invalidRow = contacts.find(
contact => !contact.name.trim() || !contact.phone.trim() || !contact.email.trim()
);
if (invalidRow) {
alert('所有联系人的姓名、电话、邮箱不能为空');
return;
}
// 2. 校验手机号格式(可选)
const phoneRegex = /^1[3-9]\d{9}$/;
const invalidPhoneRow = contacts.find(contact => !phoneRegex.test(contact.phone));
if (invalidPhoneRow) {
alert('请输入正确的手机号');
return;
}
// 3. 提交数据(实际项目中替换为接口请求)
console.log('所有联系人数据:', contacts);
alert('提交成功!联系人数据已打印到控制台');
// 4. 重置表单(可选)
setContacts([{ id: generateUniqueId(), name: '', phone: '', email: '' }]);
};
// 渲染表单
return (
<form onSubmit={handleSubmit} style={{ padding: '20px', maxWidth: '800px' }}>
<h3>动态联系人表单</h3>
<div style={{ marginBottom: '15px' }}>
{/* 渲染所有联系人行 */}
{contacts.map(contact => (
<div
key={contact.id} // 用唯一id作为key,避免DOM复用bug
style={{
display: 'flex',
gap: '10px',
alignItems: 'center',
marginBottom: '10px',
padding: '10px',
border: '1px solid #eee',
borderRadius: '4px'
}}
>
{/* 姓名输入框 */}
<input
type="text"
placeholder="姓名"
value={contact.name}
onChange={(e) => handleFieldChange(contact.id, 'name', e.target.value)}
style={{ padding: '8px', flex: 1 }}
/>
{/* 电话输入框 */}
<input
type="tel"
placeholder="手机号"
value={contact.phone}
onChange={(e) => handleFieldChange(contact.id, 'phone', e.target.value)}
style={{ padding: '8px', flex: 1 }}
/>
{/* 邮箱输入框 */}
<input
type="email"
placeholder="邮箱"
value={contact.email}
onChange={(e) => handleFieldChange(contact.id, 'email', e.target.value)}
</doubaocanvas>