React Hooks 常用用法与易混淆对比
Hooks 让函数组件能够拥有状态、执行外部同步、复用逻辑和处理性能优化。很多 Hooks 问题并不是 API 不会用,而是没有先分清:这份数据是否参与 UI 渲染?这段逻辑是否在同步外部系统?这次执行是用户主动触发,还是状态变化后必须保持同步?
本文以 React 18+ 的核心客户端 Hooks 为范围,示例使用 JavaScript。React 19 的 useActionState、useOptimistic 会在文末作为扩展入口介绍,但不替代 useState、useEffect 等基础心智模型。

先建立一张 Hooks 决策地图
- UI 会随它变化吗? 用
useState;状态转换多且复杂时考虑useReducer。 - 要连接浏览器 API、定时器、订阅、网络连接或第三方组件吗? 用
useEffect。 - 要保存 DOM 节点、计时器 ID、上一次值等不参与渲染的数据吗? 用
useRef。 - 只是跨层传递主题、当前用户、语言等数据吗? 用
useContext。 - 已经确认存在性能瓶颈吗? 再考虑
useMemo、useCallback和React.memo。 - 多个组件需要复用同一套有状态逻辑吗? 抽取自定义 Hook。
关键是:不要把所有事情都塞进 useEffect,也不要把所有变量都塞进 useRef。
1. Hooks 的两条规则:为什么必须在顶层调用?
Hooks 依赖每次渲染时稳定的调用顺序,React 才能知道这一次调用的 Hook 对应上一次的哪一个状态。因此:
- 只能在函数组件或自定义 Hook 中调用 Hook。
- 只能在组件或自定义 Hook 的顶层调用,不能放进条件、循环、事件处理器、嵌套函数或
try/catch中。
// ❌ 错误:条件变化会改变 Hook 调用顺序
function Profile({ isLoggedIn }) {
if (isLoggedIn) {
const [name, setName] = useState('DinQor');
}
return <div>...</div>;
}
// ✅ 正确:始终调用 Hook,再根据条件决定使用方式
function Profile({ isLoggedIn }) {
const [name, setName] = useState('DinQor');
if (!isLoggedIn) {
return <p>请先登录</p>;
}
return <input value={name} onChange={(e) => setName(e.target.value)} />;
}
建议启用 eslint-plugin-react-hooks。其中 rules-of-hooks 能发现调用位置错误,exhaustive-deps 能帮助发现 Effect、Memo 或 Callback 的依赖遗漏。它提供的是静态检查建议,不能替代对 Effect 逻辑的判断。
2. useState:管理会显示在界面上的状态
基础状态与渲染快照
useState 返回当前这次渲染中的状态值和更新函数:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count); // 仍然是当前这次渲染中的旧值
}
return <button onClick={handleClick}>{count}</button>;
}
setCount 不会立刻修改当前函数执行环境中的 count,而是请求 React 在后续渲染中使用新状态。可以把组件函数理解为:每次渲染都会拿到一张独立的状态快照。
连续依赖旧值时,使用函数式更新
下面的写法看似加了 3 次,实际三次都基于同一个 count 快照:
// ❌ 通常只会增加 1
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
如果下一次状态依赖前一次计算结果,应传入 updater:
// ✅ 会依次得到 c + 1、再 c + 1、再 c + 1
setCount((c) => c + 1);
setCount((c) => c + 1);
setCount((c) => c + 1);
对象和数组:不要直接修改
State 中的对象和数组应视为不可变数据。直接修改旧对象可能导致 React 无法按预期识别更新,也会让状态变化难以追踪。
function TodoEditor() {
const [todo, setTodo] = useState({ title: '', done: false });
function changeTitle(e) {
// ❌ todo.title = e.target.value;
// ✅ 创建新对象
setTodo({
...todo,
title: e.target.value,
});
}
return <input value={todo.title} onChange={changeTitle} />;
}
数组更新同理:使用 map、filter、展开运算符或其他会返回新数组的方法。
setTodos((todos) => todos.filter((todo) => todo.id !== id));
3. useEffect:不是组件加载后执行,而是同步外部系统
useEffect 用于把 React 状态与 React 之外的系统保持同步,例如:
- 浏览器事件、定时器和 DOM API;
- WebSocket、订阅和网络连接;
- 地图、图表、编辑器等第三方 UI 实例;
- 非 React 代码管理的外部资源。
function Clock() {
const [now, setNow] = useState(new Date());
useEffect(() => {
const timerId = setInterval(() => {
setNow(new Date());
}, 1000);
return () => clearInterval(timerId);
}, []);
return <time>{now.toLocaleTimeString()}</time>;
}
依赖数组的三种写法
// 1. 每次组件提交后都执行
useEffect(() => {
console.log('每次渲染提交后执行');
});
// 2. 首次提交后,以及 roomId 变化后执行
useEffect(() => {
connect(roomId);
return () => disconnect(roomId);
}, [roomId]);
// 3. 仅当 Effect 不读取会变化的组件内响应式值时使用
useEffect(() => {
startGlobalService();
return stopGlobalService;
}, []);
依赖数组不是我希望它什么时候运行的开关。Effect 中读取到的 props、state,以及组件内部声明的变量和函数,通常都属于响应式值,应如实写入依赖。
当依赖变化时,React 会先执行旧 Effect 的清理函数,再执行新 Effect 的建立逻辑。清理函数尤其适合订阅、事件监听、计时器、请求取消和外部实例销毁。
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
return <p>正在连接房间:{roomId}</p>;
}
React 18+ Strict Mode:开发环境的额外检查
在开发模式下,启用 StrictMode 时,React 可能额外执行一次 Effect 的建立和清理流程,用来检查代码能否正确完成建立、清理和再次建立。你可能因此看到请求、日志或连接初始化出现多次。
正确的处理方式不是简单删除 Strict Mode,而是确认 Effect:
- 是否真的需要存在;
- 是否返回了对称的清理函数;
- 是否具备可重复建立和销毁的能力。
4. useEffect vs 事件处理器:按原因放代码
一个实用判断是:这段逻辑是因为页面正在显示某个状态而必须同步,还是因为用户刚刚做了某个动作?
// ❌ 用户点击提交后发请求,却绕到 Effect 中
useEffect(() => {
if (submitted) {
postOrder(formData);
}
}, [submitted, formData]);
// ✅ 用户点击是直接原因,直接在事件处理器中执行
async function handleSubmit() {
await postOrder(formData);
setSubmitted(true);
}
- 事件处理器:由一次明确交互触发,如点击、提交、输入。
- Effect:由渲染结果和依赖变化驱动,用来保持与外部系统同步。
把用户行为塞进 Effect,往往会引入额外依赖、重复请求和难以理解的状态链路。
5. useRef vs useState:是否需要触发重新渲染?
useRef 保存跨渲染存在、但不用于渲染 UI 的可变数据。修改 ref.current 不会触发重新渲染。
function SearchBox() {
const inputRef = useRef(null);
function focusInput() {
inputRef.current?.focus();
}
return (
<>
<input ref={inputRef} />
<button onClick={focusInput}>聚焦输入框</button>
</>
);
}
也可以用它保存计时器 ID。组件卸载时应清理计时器:
function Stopwatch() {
const timerRef = useRef(null);
const [seconds, setSeconds] = useState(0);
useEffect(() => {
return () => clearInterval(timerRef.current);
}, []);
function start() {
if (timerRef.current) return;
timerRef.current = setInterval(() => {
setSeconds((s) => s + 1);
}, 1000);
}
function stop() {
clearInterval(timerRef.current);
timerRef.current = null;
}
return (
<button onClick={timerRef.current ? stop : start}>
{timerRef.current ? '停止' : '开始'}({seconds} 秒)
</button>
);
}
| 问题 | useState | useRef |
|---|---|---|
| 修改后是否触发重新渲染? | 会请求重新渲染 | 不会 |
| 是否适合驱动 UI? | 适合 | 不适合 |
| 常见用途 | 表单值、加载状态、列表数据 | DOM 节点、计时器 ID、外部实例 |
| 是否应在渲染期间随意修改? | 不应直接修改 | 通常也不应通过读写 current 来影响渲染 |
一句话:用户应该在界面上看到变化,就用 State;只是给代码记住一个值,就用 Ref。
6. useContext:解决跨层传递,不等于完整的全局状态方案
当主题、语言、当前用户等数据需要跨越许多中间组件传递时,Context 可以减少 props drilling。
const ThemeContext = createContext('light');
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={theme}>
<Toolbar />
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
切换主题
</button>
</ThemeContext.Provider>
);
}
function Toolbar() {
return <SaveButton />;
}
function SaveButton() {
const theme = useContext(ThemeContext);
return <button className={theme}>保存</button>;
}
Context 擅长传递共享值,但不自动解决复杂异步流程、缓存、服务端数据一致性或大规模状态拆分问题。Provider 的 value 变化时,订阅该 Context 的组件会更新;React.memo 不能阻止这类 Context 更新。
7. useReducer vs useState:复杂的是更新规则,不是状态数量
简单、独立的状态适合 useState:
const [title, setTitle] = useState('');
const [done, setDone] = useState(false);
当一个状态有多种动作、更新规则需要集中维护,或多人协作时,useReducer 往往更清晰:
function todoReducer(state, action) {
switch (action.type) {
case 'added':
return [...state, {
id: crypto.randomUUID(),
title: action.title,
done: false,
}];
case 'toggled':
return state.map((todo) =>
todo.id === action.id ? { ...todo, done: !todo.done } : todo
);
case 'removed':
return state.filter((todo) => todo.id !== action.id);
default:
throw new Error(`未知 action:${action.type}`);
}
}
function TodoList() {
const [todos, dispatch] = useReducer(todoReducer, []);
return (
<button onClick={() => dispatch({ type: 'added', title: '学习 useReducer' })}>
新增任务({todos.length})
</button>
);
}
useReducer 不一定比 useState 更高级;它只是把如何更新从组件事件中抽出来,形成可测试的纯函数。
8. useMemo、useCallback 与 React.memo:三个不同层次的缓存
这三个工具经常一起出现,但缓存对象不同:
useMemo:缓存计算结果;useCallback:缓存函数引用;React.memo:在 props 未变化时尝试跳过子组件的重新渲染。
const TodoItem = memo(function TodoItem({ todo, onToggle }) {
console.log('渲染任务:', todo.title);
return <button onClick={() => onToggle(todo.id)}>{todo.title}</button>;
});
function TodoPanel({ todos, keyword }) {
const visibleTodos = useMemo(() => {
return todos.filter((todo) => todo.title.includes(keyword));
}, [todos, keyword]);
const handleToggle = useCallback((id) => {
console.log('切换任务:', id);
}, []);
return visibleTodos.map((todo) => (
<TodoItem key={todo.id} todo={todo} onToggle={handleToggle} />
));
}
如果父组件每次渲染都创建新的函数:
<TodoItem onToggle={(id) => console.log(id)} />
即使 TodoItem 被 memo 包裹,onToggle 仍是新引用,props 会被视为变化,memo 可能无法跳过这次渲染。此时 useCallback 才可能有价值。
但请先记住优先级:
- 先写正确、清晰的代码;
- 用 React DevTools Profiler 或真实卡顿确认瓶颈;
- 再在昂贵计算、频繁重渲染的子组件或 Hook 依赖确实需要稳定引用时加缓存。
不要把 useMemo、useCallback 当作业务正确性的前提,也不要默认给每个函数和数组都套一层缓存。
9. useEffect vs useLayoutEffect:默认选 useEffect
useEffect 通常不会阻塞浏览器绘制;useLayoutEffect 会在浏览器重绘前执行,因此适合少数必须先测量布局、再立即修正 UI 的场景。
function Tooltip({ targetRef }) {
const [top, setTop] = useState(0);
useLayoutEffect(() => {
const target = targetRef.current;
if (!target) return;
const rect = target.getBoundingClientRect();
setTop(rect.bottom + 8);
}, [targetRef]);
return <div style={{ position: 'fixed', top }}>提示内容</div>;
}
这里假设 targetRef 是由父组件创建并传入的稳定 ref。由于 useLayoutEffect 可能阻塞绘制,应把它视为布局测量的专用工具。网络请求、订阅、日志、普通定时器等常规副作用仍优先使用 useEffect。
10. 自定义 Hook:复用逻辑,不共享同一份状态
自定义 Hook 的名称必须以 use 开头。它可以组合其他 Hooks,将某项可复用能力封装出来。
function useDebouncedValue(value, delay = 300) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timerId = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timerId);
}, [value, delay]);
return debouncedValue;
}
function SearchPage() {
const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebouncedValue(keyword, 300);
return (
<>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<p>用于请求的关键词:{debouncedKeyword}</p>
</>
);
}
每次调用 useDebouncedValue 都会获得独立状态;自定义 Hook 复用的是状态逻辑,而不是自动共享一份状态。命名应描述能力,例如 useOnlineStatus、useLocalStorage、useDebouncedValue,而不是用 useMount、useEffectOnce 这类名称包装并逃避依赖管理。
11. 高频错误与最小修复
直接修改 State
// ❌ user.name = 'Ava'; setUser(user);
// ✅ setUser((user) => ({ ...user, name: 'Ava' }));
为了只执行一次而忽略 Effect 依赖
// ❌ 会捕获旧的 userId
useEffect(() => {
sendAnalytics(userId);
}, []);
// ✅ 如实声明依赖
useEffect(() => {
sendAnalytics(userId);
}, [userId]);
如果需求确实是对每个组件实例只发送一次,也应先明确这个语义,再在保留正确依赖的前提下使用 ref 作为守卫,而不是关闭 lint。
Effect 无限循环
// ❌ 每次渲染都会创建新对象,依赖持续变化
const options = { roomId };
useEffect(() => {
connect(options);
}, [options]);
// ✅ 在 Effect 内创建,依赖只保留真正的响应式值
useEffect(() => {
const options = { roomId };
connect(options);
}, [roomId]);
用 Ref 保存需要展示的数据
// ❌ 改变后界面不会更新
const countRef = useRef(0);
countRef.current += 1;
// ✅ 需要显示在 UI 中时使用 State
setCount((count) => count + 1);
无差别使用 memoization
如果没有昂贵计算、没有被 memo 优化的子组件,也没有依赖稳定引用的实际需求,useMemo 和 useCallback 通常只会增加代码复杂度。
12. React 19+ 扩展:Action Hooks 放在什么位置学习?
React 19 中可以进一步了解两类与表单和异步 Action 相关的 Hook:
useActionState:管理 Action 执行后的状态、Action 分发函数和 pending 状态,适合表单提交等流程;useOptimistic:在 Action 进行期间先乐观更新 UI,等待服务端结果确认或回退。
它们解决的是更具体的异步交互问题。学习顺序建议仍然是:先掌握 useState、useEffect、useRef、useContext 和 useReducer,再进入 Action、Transition 与乐观更新。
结语:用数据是否参与渲染与逻辑为何执行做判断
React Hooks 的核心不是记住一长串 API,而是做出正确分类:
- 渲染数据放 State;
- 非渲染数据放 Ref;
- 外部同步写 Effect;
- 用户动作写事件处理器;
- 复杂更新规则交给 Reducer;
- 跨层传值使用 Context;
- 性能缓存必须由实际问题驱动。
当这些边界清晰后,依赖数组、闭包、重复渲染和 memoization 的大部分困惑都会自然减少。

2万+

被折叠的 条评论
为什么被折叠?



