使用原子状态提升 React 在深度嵌套组件树中的性能
Using Atomic State to Improve React Performance in Deeply Nested Component Trees

原始链接: https://runharbor.com/blog/2025-10-26-improving-deeply-nested-react-render-performance-with-jotai-atomic-state

Harbor的临床试验数据采集应用最初使用标准的React `useState` 和 `Context` 进行状态管理,但在处理代表复杂临床试验数据的深度嵌套组件树时遇到了性能问题。树中的一个状态更新会触发完全重新渲染,影响响应速度。 为了解决这个问题,Harbor采用了使用Jotai库的“原子状态”管理。Jotai允许开发者定义小的、独立的单位状态(“原子”),并且只有当这些特定原子发生变化时,才会重新渲染依赖于这些原子的组件。这与Context形成对比,Context中的更改通常会导致更广泛的重新渲染。 由于Jotai具有类似`useState`的API,因此过渡非常顺利。通过利用受控输入和原子状态,Harbor在显著提高性能的同时,保持了惯用的React代码,避免了完全受控或非受控输入方法的权衡。这使得能够提供响应迅速且性能良好的用户体验,这对于复杂的数据采集应用至关重要。最终,使用Jotai的原子状态在功能丰富性和实际规模性能之间取得了平衡。

使用原子状态来提升 React 在深度嵌套组件树中的性能 (runharbor.com) 10 分,由 18nleung 发表于 1 天前 | 隐藏 | 过去 | 收藏 | 1 条评论 JoeMattie 发表于 22 小时前 [–] 过去几年,我使用了一个非常相似的库 (valtio) 在几个相当大的项目中实现了这一点。实际上,我认为这两个状态管理库都是由同一个人编写的。回复 考虑申请 YC 2026 冬季批次!申请截止日期为 11 月 10 日 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系 搜索:
相关文章

原文

Atomic state has enabled us to build complex, deeply nested React component trees in our clinical trial data capture application without trading off render performance or developer ergonomics. Here's a very brief overview of the difference between vanilla React Context-based state management and atomic state management, with an interactive demo based on real-world clinical trial data showing how we use atomic state to keep Harbor's EDC UI responsive and performant.

Isometric 3d render of a tree structure made of glowing wireframe nodes, roots at bottom branching upward into dense canopy of interconnected React component boxes. Central trunk shows gradient from slow-pulsing red nodes (bottom) to fast-pulsing green nodes (top). Small atomic orbital diagrams float near top nodes.Cool teal and electric green palette with purple accents for orbital paths, dark background, sharp technical aesthetic. Subtle data flowing as light particles between nodes

In early prototypes of Harbor's clinical trial data capture UI, our React state management stack was simple: useState and Context. It was uncomplicated, easy to use, and required no extra dependencies. But — given the hierarchical nature of our data and the resulting deeply-nested component trees — we quickly ran into performance issues.

For instance, consider the nested clinical trial form structure below, which uses Context to manage state. The four-level "event → form → field group → field" hierarchy mirrors that of the schema we use to model clinical trial data internally. When a node rerenders in the demo, the component flashes (the color depends on the depth of the node in the tree). You'll notice that marking any node as completed will cause the entire tree to rerender: try clicking "Mark Completed" on a node below.

import { createContext, useContext, useMemo, useState } from 'react'
import { buildClinicalTrialTree } from './clinical-trial-tree'
import { useFlashOnRender } from './use-flash-on-render'

const CompletedNodeIdsContext = createContext([new Set(), () => {}])

export default function ContextDemo() {
  const clinicalTrialTree = useMemo(() => {
    return buildClinicalTrialTree(3, 3)
  }, [])

  const [completedNodeIds, setCompletedNodeIds] = useState(new Set())

  return (
    <>
      <CompletedNodeIdsContext.Provider
        value={[completedNodeIds, setCompletedNodeIds]}
      >
        <h1>Clinical Trial Data Tree</h1>
        <CompletedCount />
        {clinicalTrialTree.map((node) => (
          <Node key={node.id} node={node} />
        ))}
      </CompletedNodeIdsContext.Provider>
    </>
  )
}

export function CompletedCount() {
  const ref = useFlashOnRender()

  const [completedNodeIds] = useContext(CompletedNodeIdsContext)
  const completedCount = completedNodeIds.size

  return (
    <p ref={ref}>
      Completed Count: <strong>{completedCount}</strong>
    </p>
  )
}

export function Node({ node }) {
  const ref = useFlashOnRender(node)

  const [completedNodeIds, setCompletedNodeIds] = useContext(
    CompletedNodeIdsContext,
  )
  const isCompleted = completedNodeIds.has(node.id)

  return (
    <div key={node.id} ref={ref} style={{ userSelect: 'none' }}>
      <p>
        &mdash; {node.id}{' '}
        {node.type === 'field' &&
          (isCompleted ? (
            <span
              onClick={() =>
                setCompletedNodeIds((prev) => {
                  prev.delete(node.id)
                  return new Set([...prev])
                })
              }
            ></span>
          ) : (
            <button
              onClick={() =>
                setCompletedNodeIds((prev) => new Set([...prev, node.id]))
              }
            >
              Mark Completed
            </button>
          ))}
      </p>
      <div style={{ paddingLeft: '20px' }}>
        {node.children.map((child) => (
          <Node key={child.id} node={child} />
        ))}
      </div>
    </div>
  )
}

While the rerender occurs relatively quickly in this demo, the components in our actual application are significantly more complex:

  1. We not only have to handle boolean states, but also arbitrary text data, dates, selections, etc. — any data type that could be useful to a clinical study.
  2. Nodes sometimes need to be conditionally visible or validated based on the state of other nodes.
  3. Some nodes can dynamically repeat an arbitrary number of times.

This complexity makes rerenders commensurately more expensive in practice, and raises crucial questions about the way we control and manage state:

  1. Do we use "controlled" inputs and bring all state into React or take an "uncontrolled" approach and grab values on submit?
  2. What are the tradeoffs between each approach with respect to implementing conditional visibility logic, validation rules, and other UI features which have behavior that depends on the state of other nodes?
  3. What are the performance implications of each approach?

Broadly, the answer to these questions — if we restrict ourselves to simple, vanilla React — is that if we use controlled inputs, we can implement complex UI patterns in idiomatic React, where the "view is a function of state," at the cost of more frequent, potentially wide-ranging rerenders. If we use uncontrolled inputs, we can avoid unnecessary rerenders,1 but at the cost of imperative, unidiomatic workarounds to implement complex conditional UI.

So, we looked beyond vanilla React. The solution we arrived at was to embrace a state management approach called "atomic state." Specifically, we (a) use controlled inputs and bring all state into React, allowing us to implement complex UI patterns idiomatically and declaratively, but (b) manage state using a library called Jotai, which gives us tools to access subsets of state — called "atoms" — that skip rerendering when unrelated parts of the state change. Importantly, Jotai provides a hook-based API that is very similar to useState, making the migration extremely approachable from a developer ergonomics perspective.2


const [completedNodeIds, setCompletedNodeIds] = useState(new Set())


const [completedNodeIds, setCompletedNodeIds] = useContext(
  CompletedNodeIdsContext,
)


const [completedNodeIds, setCompletedNodeIds] = useAtom(
  completedNodeIdsAtom
)

Specifically, the way Jotai works is that we create different "atoms" (similar to "selectors" in Redux) for each subset of the overall state that each of our components depend on.3 The library makes sure that these atoms only trigger a rerender if their specific subset of state has actually changed.


const CompletedNodeIdsContext = createContext([new Set(), () => {}])


const [completedNodeIds, setCompletedNodeIds] = useContext(
  CompletedNodeIdsContext,
)

const completedNodeIdsAtom = atom(new Set())


const completedCountAtom = atom((get) => {
  const completedNodeIds = get(completedNodeIdsAtom)
  return completedNodeIds.size
})


const isCompletedAtomFamily = atomFamily((nodeId) => {
  return atom((get) => {
    const completedNodeIds = get(completedNodeIdsAtom)
    return completedNodeIds.has(nodeId)
  })
})


const setCompletedNodeIds = useSetAtom(completedNodeIdsAtom)

The exact implementation details of our approach — e.g., how we organize our atoms and enforce state boundaries (since atoms are effectively global), more on why we went with atom-based as opposed to selector-based global state management (i.e., the approach taken by libraries like Zustand and Redux), how we interoperate with vanilla Context, etc. — deserve a separate blog post. But for now, here's a second demo that implements the same nested tree component as above, but backed by atomic state managed by Jotai. Try clicking "Mark Completed" on one of the nodes below.

import { atom, useAtomValue, useSetAtom } from 'jotai'
import { atomFamily } from 'jotai/utils'
import { useMemo } from 'react'
import { buildClinicalTrialTree } from './clinical-trial-tree'
import { useFlashOnRender } from './use-flash-on-render'

const completedNodeIdsAtom = atom(new Set())
const completedCountAtom = atom((get) => {
  const completedNodeIds = get(completedNodeIdsAtom)
  return completedNodeIds.size
})
const isCompletedAtomFamily = atomFamily((nodeId) => {
  return atom((get) => {
    const completedNodeIds = get(completedNodeIdsAtom)
    return completedNodeIds.has(nodeId)
  })
})

export default function ContextDemo() {
  const clinicalTrialTree = useMemo(() => {
    return buildClinicalTrialTree(3, 3)
  }, [])

  return (
    <>
      <h1>Clinical Trial Data Tree</h1>
      <CompletedCount />
      {clinicalTrialTree.map((node) => (
        <Node key={node.id} node={node} />
      ))}
    </>
  )
}

export function CompletedCount() {
  const ref = useFlashOnRender()
  const completedCount = useAtomValue(completedCountAtom)

  return (
    <p ref={ref}>
      Completed Count: <strong>{completedCount}</strong>
    </p>
  )
}

export function Node({ node }) {
  const ref = useFlashOnRender(node)

  const setCompletedNodeIds = useSetAtom(completedNodeIdsAtom)
  const isCompleted = useAtomValue(isCompletedAtomFamily(node.id))

  return (
    <div key={node.id} ref={ref} style={{ userSelect: 'none' }}>
      <p>
        &mdash; {node.id}{' '}
        {node.type === 'field' &&
          (isCompleted ? (
            <span
              onClick={() =>
                setCompletedNodeIds((prev) => {
                  prev.delete(node.id)
                  return new Set([...prev])
                })
              }
            ></span>
          ) : (
            <button
              onClick={() =>
                setCompletedNodeIds((prev) => new Set([...prev, node.id]))
              }
            >
              Mark Completed
            </button>
          ))}
      </p>
      <div style={{ paddingLeft: '20px' }}>
        {node.children.map((child) => (
          <Node key={child.id} node={child} />
        ))}
      </div>
    </div>
  )
}

This time, only two components rerender when a node is marked completed: the node that was clicked, and the node that contains the current count of completed nodes. In a highly nested, complex UI, the performance benefits of this approach add up quickly!

Altogether, implementing atomic state with Jotai means:

  1. We keep our state controlled — "in React" — so our component code can stay idiomatic and declarative.
  2. We can still access state with a familiar useState-like API, enabling us to adopt atomic state without significant component-level code changes.
  3. Most importantly, these benefits do not come at the cost of shipping components that cause massive rerender cascades every time any of its internal state changes.

Atomic state has been critical in enabling us to balance feature-completeness with real-world performance in our production frontend application, and is a major part of how we're able to deliver such a great data capture experience for all of our users, across both sites and sponsors.

Reach out if you're also interested in building performant, scalable software in a mission-critical domain. It's what we do every day at Harbor.

联系我们 contact @ memedata.com