首页 / 文章 / 停止使用useEffect同步状态:一种更安全的React模式

停止使用useEffect同步状态:一种更安全的React模式

了解为何使用useEffect同步派生状态会导致竞态条件与多余的渲染,以及如何用渲染时派生和key属性来替代它。

2523 词

useEffect 视为保持状态同步的机制,会引发渲染循环、竞态条件以及虚幻的 UI 故障。

有一份标为紧急的支持工单送到了你的桌上。有客户反映,在团队控制台切换账户时,操作日志有时会显示他们30秒前查看过的账户的相关事件。

你开始检查代码库。这里并没有什么特别复杂的地方——没有 WebSocket 层,也没有工作线程,只是一个相当常见的主从式 React 页面结构。

在本地进行测试时,你依次点击不同的账户。前99次切换都正常。但在开启网络限速后的第100次尝试时,出现了视觉上的异常:有那么一瞬间,之前选定用户的付费等级会出现在新选定用户的信息卡片中,随后才恢复正常。

这种问题的根源是一种看似完全无害的模式:

useEffect(() => {
  if (selectedUserId) {
    fetchUserData(selectedUserId).then((data) => {
      setUserProfile(data);
    });
  }
}, [selectedUserId]);

这种习惯——总是使用 useEffect 来让组件内部状态与属性或其他状态保持一致——在现代前端代码中引发的隐蔽错误、界面卡顿以及结构问题,比几乎任何其他单一模式都要多。

1. 生命周期的误区:为何开发者默认选择 useEffect

当 Hooks 在 React 16.8 中出现时,那些有类组件开发经验的工程师常常将 useEffect 视为 componentDidMountcomponentDidUpdatecomponentWillUnmount 的综合替代品。

这种假设后来导致了诸多混乱。

类组件倾向于命令式风格:当属性发生变化时,需要手动在componentDidUpdate方法中调用this.setState(),以便重新计算由此派生出的所有值。

转向函数组件后,许多开发者仍保持着这种命令式习惯,他们默认只要某个属性发生变化,自己就有责任将相应的更新显式地写入本地状态中。

问题在于,React本质上是声明式的且以状态驱动。编写一个仅用于更新另一个本地状态变量的useEffect,实际上会迫使React执行两次完整的渲染过程而非一次。

具体流程如下:

  1. React使用新的属性以及仍然过时的状态来渲染组件。
  • 该渲染结果会被提交到 DOM,随后由浏览器进行绘制。
  • 效果触发后会调用 setState() 方法。
  • React 会排队执行第二次渲染,以反映更新后的状态。
  • 举个例子:

    function UserBillingSummary({
      plan,
      addonCount
    }: {
      plan: string;
      addonCount: number;
    }) {
      const [totalCost, setTotalCost] = useState(0);
    
      useEffect(() => {
        const base = plan === 'enterprise' ? 499 : 99;
        setTotalCost(base + addonCount * 25);
      }, [plan, addonCount]);  return <div>Total: ${totalCost} / month</div>;
    }
    

    实际上根本不需要单独的状态变量——totalCost 完全可以从 planaddonCount 中计算得出。

    换言之,该组件只是为了得到在初始渲染时就已经可以计算出的值而做了不必要的额外工作。

    在那个过渡帧期间,用户可能会短暂看到屏幕上不一致的数值。如果还有任何布局逻辑依赖于该计算值,浏览器可能还需要重复进行本不必重复的布局和绘制操作。

    2. 多米诺效应:级联依赖链

    一旦多个效果开始依赖彼此的输出,这种双重渲染的成本就会显著上升。

    想象一下分析仪表板中的多步骤过滤面板:

    function AnalyticsFilters({
      organizationId
    }: {
      organizationId: string;
    }) {
      const [teams, setTeams] = useState<Team[]>([]);
      const [selectedTeamId, setSelectedTeamId] = useState<string>('');
      const [projects, setProjects] = useState<Project[]>([]);
      const [selectedProjectId, setSelectedProjectId] = useState<string>('');
    
      useEffect(() => {
        fetchTeams(organizationId).then((res) => {
          setTeams(res);
          setSelectedTeamId(res[0]?.id || '');
        });
      }, [organizationId]);  useEffect(() => {
        if (selectedTeamId) {
          fetchProjects(selectedTeamId).then((res) => {
            setProjects(res);
            setSelectedProjectId(res[0]?.id || '');
          });
        }
      }, [selectedTeamId]);  useEffect(() => {
        if (selectedProjectId) {
          logAnalyticsFilterChange(selectedProjectId);
        }
      }, [selectedProjectId]);  return (
        <div className="filter-bar">
          {/* Filter UI */}
        </div>
      );
    }
    

    看看当 organizationId 发生变化时会发生什么:

    1. React 使用新的 organizationId 进行重新渲染。
    2. 第一个效果获取团队列表,然后同时更新 teamsselectedTeamId
    3. React 再次渲染。
    4. 第二个效果检测到更新后的 selectedTeamId,并获取相关项目。
    5. React 再次渲染。
    6. 第三个效果获取新的 selectedProjectId 并记录该变化。

    原本只是单个属性的更新,现在却演变成一系列状态变化和效果执行的连锁反应。

    随着应用程序的规模扩大,这类链式调用会变得极难追踪。如果网络响应顺序混乱,或者由于权限问题或其他异常情况导致某个响应为空,界面就会陷入不一致状态,而不会抛出任何明显的错误。

    真正的问题不仅在于额外的渲染次数——更在于该组件不知不觉地变成了一个微型异步状态机,而这并非任何人刻意设计的。

    3. 异步竞态条件的隐患

    useEffect 中未被妥善管理的异步调用是单页应用中出现虚假数据的另一个常见原因。

    想象一下,一名客服人员正在快速点击表格中的不同工单行:

    function TicketDetailView({
      ticketId
    }: {
      ticketId: string;
    }) {
      const [ticket, setTicket] = useState<TicketData | null>(null);
      const [loading, setLoading] = useState(true);
    
      useEffect(() => {
        setLoading(true);    api.getTicket(ticketId).then((data) => {
          setTicket(data);
          setLoading(false);
        });
      }, [ticketId]);  if (loading) return <div>Loading ticket...</div>;  return <TicketDetails ticket={ticket} />;
    }
    

    以下是导致该组件出问题的事件顺序:

    1. 客服人员点击工单#101,请求A被触发。
    2. 在请求A处理完成之前,客服人员又点击了工单#102,请求B被触发。
    3. 由于请求B先处理完成,界面现在显示的是工单#102。
    4. 最终请求A处理完成,并调用了setTicket(ticket101)函数。
    5. 侧边栏仍然高亮显示工单#102,但详细信息面板现在显示的是工单#101。

    这并非React的错,真正的问题在于该组件允许用户在已跳转到其他页面后,仍让过时旧请求覆盖状态。

    如果在没有使用任何辅助库的情况下在effect中获取数据,就需要通过手动清理来避免这种情况:

    useEffect(() => {
      let isCurrent = true;
    
      setLoading(true);  api.getTicket(ticketId)
        .then((data) => {
          if (isCurrent) {
            setTicket(data);
            setLoading(false);
          }
        })
        .catch((err) => {
          if (isCurrent) {
            handleTicketError(err);
          }
        });  return () => {
        isCurrent = false;
      };
    }, [ticketId]);
    

    如果你的 API 客户端支持的话,更好的选择是 AbortController。这样不必简单地丢弃延迟返回的响应,而是可以在请求完成之前就取消它。

    4. 更佳的解决方案:在渲染时计算派生状态

    通常,解决同步问题的最简单方法是从一开始就避免对状态进行同步。

    在很多情况下,开发者会本能地使用 useState 配合 useEffect,但实际上他们试图存储的值已经存在于当前的 props 或父组件状态中。

    无需将该值复制到本地状态中,可以直接在渲染时计算出来。

    以下是存在问题的模式:

    function OrderSummary({
      items,
      discountCode
    }: OrderSummaryProps) {
      const [discountPercent, setDiscountPercent] = useState(0);
      const [subtotal, setSubtotal] = useState(0);
      const [finalTotal, setFinalTotal] = useState(0);
    
      useEffect(() => {
        const rawSum = items.reduce(
          (acc, item) => acc + item.price * item.quantity,
          0
        );    setSubtotal(rawSum);
      }, [items]);  useEffect(() => {
        const discount = calculateDiscount(discountCode);
        setDiscountPercent(discount);
      }, [discountCode]);  useEffect(() => {
        setFinalTotal(
          subtotal - subtotal * (discountPercent / 100)
        );
      }, [subtotal, discountPercent]);  return (
        <SummaryView
          subtotal={subtotal}
          total={finalTotal}
        />
      );
    }
    

    现在将其与基于派生状态构建的版本进行比较:

    function OrderSummary({
      items,
      discountCode
    }: OrderSummaryProps) {
      const subtotal = items.reduce(
        (acc, item) => acc + item.price * item.quantity,
        0
      );
    
      const discountPercent = calculateDiscount(discountCode);  const finalTotal =
        subtotal - subtotal * (discountPercent / 100);  return (
        <SummaryView
          subtotal={subtotal}
          total={finalTotal}
        />
      );
    }
    

    对比度很重要。不存在重复的状态,也没有负责保持数值同步的机制,更没有会让finalTotalsubtotaldiscountPercent出现不一致的窗口。

    这些属性自始至终都是唯一的真实数据来源。

    当某个计算确实非常耗时时,useMemo可以让你在多次渲染之间缓存计算结果:

    const filteredTransactions = useMemo(() => {
      return rawTransactions.filter((tx) => {
        return (
          tx.amount >= minThreshold &&
          tx.category === activeCategory
        );
      });
    }, [rawTransactions, minThreshold, activeCategory]);
    

    关键在于,useMemo的存在纯粹是为了缓存计算结果——它并非用于保持两个独立状态之间的同步。

    5. 使用key属性声明式重置状态

    当可编辑表单需要在被编辑的实体发生变化时重置其字段时,就会出现类似的陷阱。

    人们通常会采取这样的处理方式:

    function EditUserModal({
      user
    }: {
      user: UserData;
    }) {
      const [name, setName] = useState(user.name);
      const [role, setRole] = useState(user.role);
    
      useEffect(() => {
        setName(user.name);
        setRole(user.role);
      }, [user.id]);  return (
        <form>
          <input
            value={name}
            onChange={(e) => setName(e.target.value)}
          />      <select
            value={role}
            onChange={(e) => setRole(e.target.value)}
          />
        </form>
      );
    }
    

    这种做法会导致屏幕上出现短暂的视觉闪烁,因为上一条记录的数据会在效果触发并更新输入内容之前在屏幕上显示一帧。

    更糟糕的是,如果在编辑过程中有新数据到达,它可能会悄悄覆盖用户正在输入的内容。

    React已经为此提供了内置的声明式解决方案:即key属性。

    在父组件中:

    function UserAdminPage() {
      const [selectedUser, setSelectedUser] =
        useState<UserData | null>(null);
    
      return (
        <div>
          <UserList onSelectUser={setSelectedUser} />      {selectedUser && (
            <EditUserForm
              key={selectedUser.id}
              initialUser={selectedUser}
            />
          )}
        </div>
      );
    }
    

    这样,子组件的本地状态就会保持简单,无需进行任何同步处理:

    function EditUserForm({
      initialUser
    }: {
      initialUser: UserData;
    }) {
      const [name, setName] = useState(initialUser.name);
      const [role, setRole] = useState(initialUser.role);
    
      return (
        <form>
          <input
            value={name}
            onChange={(e) => setName(e.target.value)}
          />      <select
            value={role}
            onChange={(e) => setRole(e.target.value)}
          />
        </form>
      );
    }
    

    keyuser-1变为user-2时,React不会尝试更新现有的组件——它会丢弃该组件并挂载一个全新的实例,其状态会根据新的用户数据重新初始化。

    完全不需要任何同步处理。

    6. 代码的真正归属:事件处理程序与效果

    一个有用的思维模型是:效果的存在是为了让组件与 React 之外的内容保持同步,而事件处理程序则是为了响应用户的操作。

    假设每当有人点击“提交订单”时,你需要触发一次分析事件并显示确认提示。

    一种实现方式如下:

    function CheckoutButton({
      orderId
    }: {
      orderId: string;
    }) {
      const [submitted, setSubmitted] = useState(false);
    
      useEffect(() => {
        if (submitted) {
          analytics.track('order_submitted', {
            orderId
          });      showToast('Order placed successfully!');
        }
      }, [submitted, orderId]);  return (
        <button onClick={() => setSubmitted(true)}>
          Place Order
        </button>
      );
    }
    

    问题在于这种方式将触发条件和实际操作分开了——点击操作会设置一个标志,然后由效果在之后对该标志作出响应。

    更简洁的做法是将所有操作都放在处理程序内部:

    function CheckoutButton({
      orderId
    }: {
      orderId: string;
    }) {
      const handlePlaceOrder = async () => {
        await submitOrderApi(orderId);
    
        analytics.track('order_submitted', {
          orderId
        });    showToast('Order placed successfully!');
      };  return (
        <button onClick={handlePlaceOrder}>
          Place Order
        </button>
      );
    }
    

    现在因果关系已经非常清晰:用户点击后,订单会被提交,后续操作会作为同一事件的一部分立即执行。不存在需要检测并作出反应的中间状态变化。

    7. 何时真正有必要使用 useEffect?

    以上内容并不意味着 useEffect 本身存在缺陷。

    问题出现在人们将其当作在 React 不同状态之间传递数据的万能工具时。

    它的真正作用是让组件与 React 渲染模型之外的内容保持同步。

    那些“React 之外的内容”通常属于以下几类:

    • 原生浏览器 API,如 window.addEventListenerIntersectionObservermatchMedia
  • 必需的第三方库,如 Mapbox、Chart.js 或视频播放器 SDK
  • 实时连接,例如 WebSockets 或 Server-Sent Events
  • 直接的 DOM 操作,如更新 document.title
  • 在窗口大小改变时追踪浏览器的视口宽度就是一个合理使用的示例:

    function useWindowWidth() {
      const [width, setWidth] = useState(
        () => window.innerWidth
      );
    
      useEffect(() => {
        const handleResize = () => {
          setWidth(window.innerWidth);
        };    window.addEventListener(
          'resize',
          handleResize
        );    return () => {
          window.removeEventListener(
            'resize',
            handleResize
          );
        };
      }, []);  return width;
    }
    

    在这种情况下,这种做法能够实现单纯渲染无法完成的功能:它负责订阅浏览器事件,并在清理时取消该订阅。

    这正是 useEffect 所设计的用途。

    我目前遵循的架构规则

    当基于 React 的仪表板或应用程序开始变得迟缓、行为不稳定,或者出现难以复现的时序错误时,首先应该检查的是代码库中 useEffect 的使用方式。

    有四项指导原则能够解决大多数问题。

    1. 在渲染时计算数值。 所有可从属性或现有状态中得出的值都应直接在渲染代码中计算。只有当该计算确实耗时昂贵时,才使用 useMemo

    2. 将用户触发的逻辑放在事件处理函数中。 当因点击、按键、选择或提交等操作引发某些行为时,相关逻辑应与触发该操作的事件放在一起,而非分散在单独的效应函数中。

    3> 使用 key 属性彻底重置状态。 在切换不同实体时,应生成全新的组件实例,让 React 重新挂载该组件,而非手动同步每个字段。

    4. 仅将 useEffect 用于真正的外部同步。 浏览器事件、订阅机制、WebSocket 连接以及命令式库集成才是使用 useEffect 的合适场景。

    目的并非完全从代码库中移除 useEffect

    而是要停止将其当作在 React 状态的不同部分之间传递值的临时手段。

    一旦将派生值视为派生值,用户操作被当作事件处理,而只有真正的外部系统才通过 useEffect 进行连接,React 组件就会变得极易理解。

    此外,那些只有在多次点击后、在网络连接缓慢的情况下或仅在生产环境中才会出现的神秘错误,也能在源头就更容易被避免。

    相关阅读

  • 使用 Service Workers 为 Web 应用启用离线支持 — 了解如何利用 Service Workers 和 Cache API 让网站即时加载,并在无网络连接时仍能正常运行。
  • React 渲染原理:状态更新到屏幕像素 — 了解 React 的渲染、协调和提交阶段如何与浏览器的布局、绘制和合成流程相衔接,最终生成像素。
  • 解决搜索界面中防抖无法处理的竞态条件问题 — 了解为何仅靠防抖无法防止过时的 API 响应覆盖最新的界面状态,并探索四种实用的解决方案来确保请求按顺序处理。
  • 为何 React useEffect 回调函数绝不能是异步函数 — 了解为何从 useEffect 中返回异步函数会破坏 React 的清理机制,并查看四种安全处理异步逻辑的正确方式。