把 React 基础画成一张地图:组件边界、Hooks、状态放哪里不再纠结
state 放哪里、useEffect 何时用、组件在哪儿切——把 React 的判断轴汇成一张地图,配可运行代码和 Claude Code 的协作方式。
「React 到底该把什么写在哪儿?」
这是我被问得最多的一个问题。教程看完了,useState、useEffect 的名字都认识,可一开始动手做页面,手就停住了。这个 state 该放在这个组件里,还是往父组件上提?数据是用 useEffect 拉,还是不用?组件到底在哪里切一刀?
纠结到最后,干脆把所有东西塞进一个巨大的组件里。能跑,但三天后的自己读不懂。我最开始也是这么过来的。
在 React 里卡住的原因,九成不是语法,而是手里没有一套设计的判断轴。这篇文章就是要把那套判断轴画成一张地图。各个细节(Hooks 的深入用法、状态管理库怎么选、表格和表单怎么实现)我会在中途分流到对应专题,你先在这里把全局看清楚。
本文要点
- React 的设计,只要定好「state 放在哪里」和「组件的边界在哪里」这两件事,九成的混乱就消失了。
useEffect是用来「和外部世界同步」的。能从 props 和 state 算出来的值,不需要 Effect。- 重渲染不是坏事。只在你真的感觉到卡的时候,才加
useMemo或memo,别提前优化。 - 让 Claude Code 帮忙时,先别说「帮我做」,而是把边界、状态、数据形状、验证方式这四点交给它,输出会稳得多。
- 库(Zustand、TanStack Query、React Hook Form)要先用原生 React 把意图表达清楚,再去选,这样不容易翻车。
先用一句话说清 React 的思路
React 就是一套「状态(state)一变,就把画面重新画成跟那个状态匹配的样子」的机制。
这正是它和过去做法最不一样的地方。从前用 jQuery,你得手动下命令:「按钮被点了,就把这个元素的文字改掉」。React 反过来,你写的是「现在这个状态下,画面应该长这样」的结果。状态一变,React 自己算出差异,只把需要变的地方重画。
所以设计的主角不是外观(HTML),而是状态。先想「这个画面需要记住哪些值」「这些值放在哪里」。这是 React 开发的起点,这一步错位了,后面会全程难受。
设计的九成:state 放在哪里
state 的落点大致有四个选项。从上往下逐个考虑,上面不够用再往下挪,这是诀窍。
| 落点 | 适合的值 | 具体例子 |
|---|---|---|
| 不放(用计算得出) | 能从 props 或 state 推导的值 | 过滤后的数组、合计金额、显示用标签 |
| 单个组件内部(局部) | 只有这个组件用的值 | 展开/收起标志、输入框里正在敲的文字 |
| 提到父组件(状态提升) | 兄弟组件要共享的值 | 当前选中的标签页、筛选条件 |
| URL / 服务器端 | 想共享、想能复现的状态 | 搜索关键词、页码、API 返回的数据 |
最常犯的错,是直接跳过第一行。把「过滤后的用户列表」特意塞进 useState,再用 useEffect 重新算一遍——这完全不需要。只要有原始数组和筛选条件,每次渲染时算一下就行了。state 越多,需要操心「同不同步」的地方就越多,bug 也就越多。
反过来,要是「兄弟组件之间想看同一个值」,那就毫不犹豫地提到共同的父组件上。这跟 React 官方在 Thinking in React 里讲的思路一模一样:先找出状态的最小单位,再把它放在所有需要它的组件能共享的、最低的那个位置。就这么简单。
等状态跨页面变复杂了,才轮到库登场。不过像「先上 Redux 再说」这样扑上去,只会越用越重。按规模和种类来选的判断轴,我拆到状态管理库怎么选单独写了,等你觉得 useState 撑不住了再去读。
组件在哪里切一刀
另一个轴是边界。判断标准很简单:「能不能一句话说清这个组件的 props」。
比如 UserStatusBadge,你能说清它「收一个 status,输出一个带颜色的标签」。这是个好组件。可要是 UserTable 里塞进了搜索逻辑、API 调用、编辑弹窗的开关、保存处理,那就一句话说不完了。这就是该切的信号。
切的时候,把职责分成三类,思路会清晰很多。
- 只管外观的组件(UI):收 props 然后展示,不持有状态。
- 持有状态和逻辑的组件(容器):取数据,再分发给子组件。
- 可复用的逻辑(自定义 Hook):把状态和副作用挪进函数里。
要当心的是,切得太碎同样是失败。把 UserNameText、UserEmailText、UserRoleText 这样一行一个地拆成组件,只是文件数变多,职责一点没理清。拆分只在「要复用」「想分开状态」「想更好测」这三件事上有效时才值得。把「用到第二处之前不做通用化」当口头禅,能挡掉很多过度设计。
useEffect 的正确位置
这里只在地图的范围内,把 Hooks 的事情理一下。
useState 是「画面要记住的值」;useEffect 是用来「和 React 外部的世界同步」的。分清这个区别,Hooks 九成的纠结都没了。
所谓「外部世界」,指的是 API 通信、localStorage、定时器、订阅浏览器事件这类——除了画画面以外、要碰外部的处理。换句话说,能从 props 和 state 算出来的值,不需要 useEffect。这是 React 里最常见的错,官方甚至专门做了一页 You Might Not Need an Effect。
拿不准的时候,按这个顺序想一遍:
- 能从 props 或 state 算出来吗? → 渲染过程中直接算(不用 Effect)。
- 是用户操作触发的吗? → 写进事件处理函数(点击时 fetch 之类)。
- 是画面的显示本身需要跟外部同步吗? → 这时才用
useEffect。
落到第三条的,比如「只在打开这个画面期间,去服务器取数据并显示」。下面这个自定义 Hook 就是这种情况的实例。
可直接复制运行:把取数逻辑挪进自定义 Hook
光看地图太枯燥,这里放一段能动手的。Vite + React + TypeScript 里直接能跑的取数自定义 Hook。useEffect 的「正确用法」和善后(清理)的写法,一次就看明白。
要点有两个。用 AbortController 中止旧请求;以及卸载之后别再碰 state。这两点一忘,在快速切换画面时就会冒出「旧结果覆盖新画面」这种不起眼的 bug。
import { useEffect, useState } from "react";
// 用一个值表示取数状态(避免把 loading / success / error 搞混)
type FetchState<T> =
| { status: "loading"; data: null; error: null }
| { status: "success"; data: T; error: null }
| { status: "error"; data: null; error: string };
export function useFetch<T>(url: string): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({
status: "loading",
data: null,
error: null,
});
useEffect(() => {
// 用来中途打断旧请求的「中止按钮」
const controller = new AbortController();
setState({ status: "loading", data: null, error: null });
async function run() {
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = (await res.json()) as T;
setState({ status: "success", data: json, error: null });
} catch (err) {
// 中止不算「失败」,所以什么都不做,直接退出
if (err instanceof DOMException && err.name === "AbortError") return;
setState({
status: "error",
data: null,
error: err instanceof Error ? err.message : "未知错误",
});
}
}
void run();
// 在 url 改变前、画面消失前,把正在跑的通信中止掉
return () => controller.abort();
}, [url]);
return state;
}
用的一方是这样。靠 status 分支,所以不会做出「明明有数据却还在 loading」这种自相矛盾的状态。
type User = { id: string; name: string };
export function UserList() {
const result = useFetch<User[]>("/api/users");
if (result.status === "loading") return <p>加载中…</p>;
if (result.status === "error") return <p role="alert">失败了:{result.error}</p>;
return (
<ul>
{result.data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
这就是「Effect 正确用法」的范式。实战里你会想要缓存和重新取数,到那一步就换到 TanStack Query 的缓存设计。自己手写,只是为了把「把 UI 和通信分开」的感觉刻进身体里。
重渲染并不可怕
经常听到「React 重渲染太多所以慢」,先说在前面,重渲染本身大多数情况下几乎是免费的。React 只把差异反映到 DOM 上,函数被重新执行,并不等于画面会闪。
可要是提前到处贴 useMemo 和 React.memo,只会让代码更难读,而且通常并不会变快。顺序反了。正确的顺序是这样:
- 先把 state 放到该放的地方(别把无关的组件也卷进来一起重画)。
- 列表的
key用稳定的 ID(别拿 index 当 key)。 - 沉重的计算、巨大数组的处理,真的感到卡了之后再用
useMemo包起来。 - 输入过程中会跑重处理,就加防抖(debounce,把高频调用稀释掉)。
连「为什么会发生重渲染」都说不清就加 memo,就像往不疼的地方贴膏药。先测量、定位瓶颈,再去修。这套思路我整理在了Web 性能优化的优先级里。
用 Claude Code 高效地做
把到这里的判断轴,交给 Claude Code,而不是甩给它,这是诀窍。只说「做个像样的后台页面」,出来的东西外观是齐整,但状态散得到处都是。我自己就栽过,出来的巨型组件最后几乎重写了一遍。
现在动手之前,我一定会用文字把下面这四点交出去。
| 要交的信息 | 写法示例 |
|---|---|
| 组件的职责 | 「把列表、搜索、编辑对话框拆成不同组件」 |
| state 的落点 | 「筛选条件放父组件,只有行的展开/收起放局部」 |
| 数据的形状 | 「User 类型是这个。API 响应的形状别改」 |
| 验证方式 | 「用 Testing Library 写按 role 和 label 取元素的测试」 |
而个人觉得最管用的一招,是把**「新建」换成「让它审查」**。让它读现有的 diff,挑出「组件是不是太大」「有没有多余的 useEffect」「state 和服务器数据是不是混在了一起」。这样它不太会擅自新增文件,改动范围也能压得很小。
团队里做的话,把这四点连同禁止事项(过度通用化、只有 placeholder 的表单、拿 index 当 key 等)写进 CLAUDE.md,就不用每次重复同样的说明了。类型相关的地基看TypeScript 开发实战技巧,测试怎么铺开看测试策略的优先级,都是一脉相承的。
常见问题
Q. useState 和 useRef 怎么区分着用?
A. 想反映到画面上的值用 useState,不用反映的值用 useRef。比如「输入框的 DOM 引用」「保存上一次的值」不需要重画,就用 useRef;值一改就希望画面跟着变,就用 useState。改 useRef 的值不会触发重渲染。
Q. props 超过几个就该拆组件? A. 看的不是数量,而是「说不说得清」。props 只有 3 个但职责各不相干,就太大了;有 6 个但全为同一个目的,就没问题。props 变多了,先怀疑「能不能合成一个对象」「是不是其实混进了两个组件」。
Q. useEffect 里取的数据,切换画面时会瞬间显示成旧的。
A. 是清理(善后)漏了。像上面的代码那样用 AbortController 中止上一次通信,务必写上 return () => controller.abort()。这样「旧结果覆盖新画面」的竞态就消失了。
Q. 状态管理库要不要一开始就上?
A. 不用。先用 useState 加「提到父组件」推进,等状态跨页面缠成一团了再考虑也来得及。服务器数据本来就和 UI 状态是两回事,先到状态管理怎么选里把种类分开,再去选。
Q. Claude Code 老是做出过于「高大上」的通用组件。 A. 一开始就告诉它「只做当下需要的抽象」「用到第二处之前不通用化」。为了一个画面就被造出通用 Table、通用 Modal、通用 FilterEngine,将来一改就会一下子变得很重。
总结
React 的设计,归根结底就是回答两个问题:「这个值放在哪里?」和「这个组件的职责能不能一句话说清?」。state 的落点从上往下考虑,能用计算得出的值不碰 Effect,重渲染等到感觉卡了再动手。光这些,就能写出读得懂的代码。
而 Claude Code,你把这套判断轴交给它,它一下就成了靠得住的搭档;反过来,把判断轴也一起甩出去,回来的就是看着体面、运维起来脆弱的代码。先从一个小画面起步,把边界、状态、数据形状、验证方式这四点交出去,试试看。
想再往细处深挖的话:状态管理看状态管理怎么选,数据获取看 TanStack Query 的缓存设计,表单看 react-hook-form × zod 的校验,无障碍看 a11y 实现的优先级。想要动手的教材或可以请教的地方,也欢迎看看教材一览和 ClaudeCodeLab 培训与咨询。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
让 Claude Code 真正进入可验证的工作流
先用免费 PDF 固定基础,再用 Gumroad 教材复用工作流;如果涉及团队导入、权限或收入路径,可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。