React Hooks 常用用法与易混淆对比:用正确心智模型写好函数组件

原文链接

React Hooks 常用用法与易混淆对比

Hooks 让函数组件能够拥有状态、执行外部同步、复用逻辑和处理性能优化。很多 Hooks 问题并不是 API 不会用,而是没有先分清:这份数据是否参与 UI 渲染?这段逻辑是否在同步外部系统?这次执行是用户主动触发,还是状态变化后必须保持同步?

本文以 React 18+ 的核心客户端 Hooks 为范围,示例使用 JavaScript。React 19 的 useActionStateuseOptimistic 会在文末作为扩展入口介绍,但不替代 useStateuseEffect 等基础心智模型。

一张按问题类型选择 React Hook 的流程图:会影响 UI 的数据使用 useState 或 useReducer;外部系统同步使用 useEffect;不触发渲染的可变数据使用 useRef;跨层共享使用 useContext;确认性能瓶颈后使用 useMemo、useCallback 和 React.memo。

先建立一张 Hooks 决策地图

  • UI 会随它变化吗?useState;状态转换多且复杂时考虑 useReducer
  • 要连接浏览器 API、定时器、订阅、网络连接或第三方组件吗?useEffect
  • 要保存 DOM 节点、计时器 ID、上一次值等不参与渲染的数据吗?useRef
  • 只是跨层传递主题、当前用户、语言等数据吗?useContext
  • 已经确认存在性能瓶颈吗? 再考虑 useMemouseCallbackReact.memo
  • 多个组件需要复用同一套有状态逻辑吗? 抽取自定义 Hook。

关键是:不要把所有事情都塞进 useEffect,也不要把所有变量都塞进 useRef

1. Hooks 的两条规则:为什么必须在顶层调用?

Hooks 依赖每次渲染时稳定的调用顺序,React 才能知道这一次调用的 Hook 对应上一次的哪一个状态。因此:

  1. 只能在函数组件或自定义 Hook 中调用 Hook。
  2. 只能在组件或自定义 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} />;
}

数组更新同理:使用 mapfilter、展开运算符或其他会返回新数组的方法。

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>
  );
}
问题useStateuseRef
修改后是否触发重新渲染?会请求重新渲染不会
是否适合驱动 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)} />

即使 TodoItemmemo 包裹,onToggle 仍是新引用,props 会被视为变化,memo 可能无法跳过这次渲染。此时 useCallback 才可能有价值。

但请先记住优先级:

  1. 先写正确、清晰的代码;
  2. 用 React DevTools Profiler 或真实卡顿确认瓶颈;
  3. 再在昂贵计算、频繁重渲染的子组件或 Hook 依赖确实需要稳定引用时加缓存。

不要把 useMemouseCallback 当作业务正确性的前提,也不要默认给每个函数和数组都套一层缓存。

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 复用的是状态逻辑,而不是自动共享一份状态。命名应描述能力,例如 useOnlineStatususeLocalStorageuseDebouncedValue,而不是用 useMountuseEffectOnce 这类名称包装并逃避依赖管理。

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 优化的子组件,也没有依赖稳定引用的实际需求,useMemouseCallback 通常只会增加代码复杂度。

12. React 19+ 扩展:Action Hooks 放在什么位置学习?

React 19 中可以进一步了解两类与表单和异步 Action 相关的 Hook:

  • useActionState:管理 Action 执行后的状态、Action 分发函数和 pending 状态,适合表单提交等流程;
  • useOptimistic:在 Action 进行期间先乐观更新 UI,等待服务端结果确认或回退。

它们解决的是更具体的异步交互问题。学习顺序建议仍然是:先掌握 useStateuseEffectuseRefuseContextuseReducer,再进入 Action、Transition 与乐观更新。

结语:用数据是否参与渲染与逻辑为何执行做判断

React Hooks 的核心不是记住一长串 API,而是做出正确分类:

  • 渲染数据放 State;
  • 非渲染数据放 Ref;
  • 外部同步写 Effect;
  • 用户动作写事件处理器;
  • 复杂更新规则交给 Reducer;
  • 跨层传值使用 Context;
  • 性能缓存必须由实际问题驱动。

当这些边界清晰后,依赖数组、闭包、重复渲染和 memoization 的大部分困惑都会自然减少。

参考资料

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

DsirNg

加油努力,千万不要放弃

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值