先说一句:这篇不是让你别用 AI 写 React。
我自己每天用 Claude Code + Cursor,产出效率至少翻倍。但要说得罪的话------AI 生成的 React 代码,直接合并的风险,比你想象的高。
不是因为 AI 语法错。语法它比你还熟。
是因为 React 的"最佳实践"是一堆上下文相关 的判断:什么时候用 useEffect、什么时候不用;state 该放哪一层;key 用什么;要不要 memo。这些判断需要看这段代码所在的整个组件树,而 AI 大多数时候只看到你贴过去的那几行。
于是就有了下面这 6 个几乎每天都能在 AI 生成的 PR 里看到的坏味道。每一个我都给出真实的对比代码,你合并前扫一眼就能看出来。
1. 把渲染时能算的东西塞进 useEffect
这是 AI 生成 React 代码里最普遍的坏味道,没有之一。
AI 常见写法
tsx
function OrderList({ orders }: { orders: Order[] }) {
const [total, setTotal] = useState(0);
const [filtered, setFiltered] = useState<Order[]>([]);
useEffect(() => {
const filteredOrders = orders.filter(o => o.status === 'paid');
setFiltered(filteredOrders);
setTotal(filteredOrders.reduce((sum, o) => sum + o.amount, 0));
}, [orders]);
return (
<div>
<p>合计: {total}</p>
<ul>{filtered.map(o => <li key={o.id}>{o.name}</li>)}</ul>
</div>
);
}
为什么坏
三层问题:
- 多渲染一轮。orders 变 → 组件渲染(拿到旧 total/filtered) → useEffect 跑 → setState → 再渲染一次。用户能感知的白屏/闪一下,很多来源于此。
- state 和 props 会脱同步。如果哪天你多加一个 setFiltered 的分支忘了同步 total,数据就错了。
- 依赖数组稍微写错就是死循环。AI 写这类代码经常忘依赖或多依赖。
正确写法
派生数据直接在渲染阶段算,不要 state,也不要 effect:
tsx
function OrderList({ orders }: { orders: Order[] }) {
const filtered = orders.filter(o => o.status === 'paid');
const total = filtered.reduce((sum, o) => sum + o.amount, 0);
return (
<div>
<p>合计: {total}</p>
<ul>{filtered.map(o => <li key={o.id}>{o.name}</li>)}</ul>
</div>
);
}
如果 filter 真的很贵(比如上万条+复杂计算),再考虑 useMemo。先别急着优化。
判断标准
看到 AI 生成的 useEffect,第一个问自己:"这个逻辑,能不能直接写在 return 上面?" 大多数情况都能。
2. 把 props 复制到 useState
AI 常见写法
tsx
function UserForm({ user }: { user: User }) {
const [name, setName] = useState(user.name);
const [email, setEmail] = useState(user.email);
return (
<>
<input value={name} onChange={e => setName(e.target.value)} />
<input value={email} onChange={e => setEmail(e.target.value)} />
</>
);
}
看上去很合理:表单要能编辑,所以得有 state。
为什么坏
useState(user.name) 里的 user.name 只在组件第一次挂载时被读一次 。之后父组件传入新的 user,name 也不会更新。
这个 bug 特别隐蔽------本地开发切换用户能看到,但 AI 写完你跑一遍单个用户流程,PR 就过了。
上线之后一切换用户,表单显示上一个用户的数据,老板会问:"你测过没?"
正确写法
明确表单是"受控还是非受控"。两种解:
A. 完全受控,state 在父组件
tsx
function UserForm({ user, onChange }: {
user: User;
onChange: (patch: Partial<User>) => void
}) {
return (
<>
<input
value={user.name}
onChange={e => onChange({ name: e.target.value })}
/>
<input
value={user.email}
onChange={e => onChange({ email: e.target.value })}
/>
</>
);
}
B. 内部 state,但要用 key 强制重挂载
tsx
<UserForm key={user.id} user={user} />
一切换 user.id,React 会销毁旧组件重新创建,state 也会用新的 initial value。
判断标准
看到 useState(props.xxx),九成是错的。要么把 state 提上去,要么用 key 隔离。
3. useEffect 里 fetch,不处理竞态
AI 常见写法
tsx
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(setUser);
}, [userId]);
return user ? <div>{user.name}</div> : <Skeleton />;
}
你能一眼看出问题吗?
为什么坏
用户快速点击不同的用户 A → B → C,fetch 会按顺序发出 ,但返回顺序不一定。
如果 A 的请求走网络慢,C 已经回来了显示了 C 的名字,然后 A 的响应姗姗来迟------页面会突然变回 A 的数据。
这不是理论,这是每一个做过用户列表页的前端都栽过的坑。AI 几乎从不主动处理。
正确写法
用 AbortController 或 stale flag:
tsx
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const ctrl = new AbortController();
fetch(`/api/users/${userId}`, { signal: ctrl.signal })
.then(r => r.json())
.then(setUser)
.catch(err => {
if (err.name !== 'AbortError') throw err;
});
return () => ctrl.abort();
}, [userId]);
return user ? <div>{user.name}</div> : <Skeleton />;
}
或者更省心,直接用数据请求库:SWR、React Query、TanStack Query 都自带竞态处理。
判断标准
useEffect 里但凡出现异步,cleanup 函数一定要写。没写就是坏味道。
4. 列表 key 用 index
AI 常见写法
tsx
{items.map((item, i) => (
<ListItem key={i} data={item} />
))}
写这一行时 AI 甚至不会犹豫。
为什么坏
当列表顺序变化 、中间插入 、删除时,React 会用 key 去匹配前后两次渲染的元素。用 index 做 key,等于告诉 React:"第一位永远是第一位"------但你换了内容它认不出来。
后果:
- 列表项里如果有
<input>,用户输入到一半,你在上面插入一项,焦点就跑了 - 列表项如果是带内部 state 的组件,state 会串位
- 动画/transition 会错乱
正确写法
用数据本身的稳定 id:
tsx
{items.map(item => (
<ListItem key={item.id} data={item} />
))}
如果数据真的没 id,前端可以在拿到数据时用 crypto.randomUUID() 或自增计数补一个,但要保证同一个数据永远拿同一个 id------不能每次渲染新生成。
判断标准
看到 key={index} key={i} key={idx},直接怀疑。除非这个列表永远不会重排、不会插入删除、不会有内部 state------才 100% 安全。
5. 内联对象 / 函数,搭配 memo 组件 = memo 失效
AI 常见写法
tsx
const Row = React.memo(({ style, onClick, data }: Props) => {
return <div style={style} onClick={onClick}>{data.name}</div>;
});
function Table({ rows }: { rows: Row[] }) {
return rows.map(row => (
<Row
key={row.id}
style={{ padding: 8, borderBottom: '1px solid #eee' }}
onClick={() => console.log(row.id)}
data={row}
/>
));
}
看上去合理:子组件 memo 了,应该优化了。
为什么坏
style={{ padding: 8 }} 和 onClick={() => ...} 每次渲染都是新的引用。
React.memo 默认用 Object.is 做浅比较,新旧引用不等 → memo 直接失效 → 每次父组件渲染,所有 Row 都重渲染。
你以为你优化了,其实一点没优化,还多了个浅比较的开销。
正确写法
内联对象提到外面(如果是纯静态的):
tsx
const rowStyle = { padding: 8, borderBottom: '1px solid #eee' };
function Table({ rows }: { rows: Row[] }) {
const handleClick = useCallback((id: string) => {
console.log(id);
}, []);
return rows.map(row => (
<Row
key={row.id}
style={rowStyle}
onClick={handleClick}
rowId={row.id}
data={row}
/>
));
}
const Row = React.memo(({ style, onClick, rowId, data }: Props) => {
return <div style={style} onClick={() => onClick(rowId)}>{data.name}</div>;
});
注意:让 Row 内部生成 () => onClick(rowId) 才能保证 onClick 是稳定引用。
判断标准
看到 React.memo 出现,立即扫一眼调用处的 props------如果传的是 {...}、[...]、() => ...,memo 直接白包。
6. useMemo / useCallback 无脑加
这条和上一条正好相反------AI 也经常反过来滥用 memo。
AI 常见写法
tsx
function Greeting({ name }: { name: string }) {
const greeting = useMemo(() => `Hello, ${name}!`, [name]);
const handleClick = useCallback(() => alert(greeting), [greeting]);
return <button onClick={handleClick}>{greeting}</button>;
}
三行代码,两个 hook。
为什么坏
useMemo 和 useCallback 本身有成本:
- 每次渲染都要跑依赖数组的浅比较
- React 内部要维护一个额外的记忆槽
- 代码可读性直线下降
一个字符串拼接,直接计算比 useMemo 快十倍。给 button 传的 onClick 也不是 memo 组件,useCallback 完全没意义。
滥用 memo 的代码,通常比不加 memo 更慢------因为该做的事情没少做,还多做了一层浅比较。
正确写法
tsx
function Greeting({ name }: { name: string }) {
const greeting = `Hello, ${name}!`;
return <button onClick={() => alert(greeting)}>{greeting}</button>;
}
只在下面两种情况才加 memo:
- 计算真的贵(比如上万条数据的 filter/sort/reduce)
- 值/函数要作为 prop 传给 memo 组件,或者作为 useEffect/useMemo 的依赖
除此之外------不加就是对的。
判断标准
看到 useMemo/useCallback,反问一句:"如果去掉它,哪里会重新计算/重渲染?" 答不上来,就删掉。
最后:一份合并前 60 秒 checklist
如果你没时间读完全文,把下面这段贴到你的 Code Review 模板里:
bash
□ useEffect 里的逻辑,能不能挪到渲染阶段?
□ useState(props.xxx) 出现了吗?出现就是错。
□ useEffect 里的 fetch,有没有 AbortController 或 cleanup?
□ 列表 key 是不是稳定 id?不是就改。
□ React.memo 组件的 props,有没有内联对象/函数/数组?
□ useMemo/useCallback,如果删掉会怎样?说不上来就删。
六个问题,合并前扫一眼一分钟搞定。
一句话总结
AI 生成的 React 代码,语法都对,但 React 不是纯粹语法。
它是一套"什么时候该干什么、什么时候该什么都不干"的判断力。AI 看不到你的组件树、看不到用户如何交互、看不到线上的性能数据------这些判断,现在还得你来做。
用 AI 写代码不丢人,合并前不看 AI 写的什么,才丢人。