Sunday, October 26th, 2025

Using Atomic State to Improve React Performance in Deeply Nested Component Trees

Nathan Leung
Engineering

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.

Beyond Context: Atomic State

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

// Before: `useState`
const [completedNodeIds, setCompletedNodeIds] = useState(new Set())

// Before: `useContext`
const [completedNodeIds, setCompletedNodeIds] = useContext(
  CompletedNodeIdsContext,
)

// After: `useAtom`
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.

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

// Rerenders whenever `completedNodeIds` changes
const [completedNodeIds, setCompletedNodeIds] = useContext(
  CompletedNodeIdsContext,
)

// After
const completedNodeIdsAtom = atom(new Set())

// Only rerenders when the count of completed nodes has changed
const completedCountAtom = atom((get) => {
  const completedNodeIds = get(completedNodeIdsAtom)
  return completedNodeIds.size
})

// Only rerenders when the completion status of a specific node has changed
const isCompletedAtomFamily = atomFamily((nodeId) => {
  return atom((get) => {
    const completedNodeIds = get(completedNodeIdsAtom)
    return completedNodeIds.has(nodeId)
  })
})

// The setter is stable, so this never causes a rerender on its own
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.

Footnotes

  1. react-hook-form famously takes the uncontrolled approach and achieves impressive performance. We actually do use react-hook-form in many parts of our application; for instance, on our settings and sign up pages. But for the actual clinical trial data capture UI — which requires precise control over data flow, dependent validation, conditional visibility, etc. — it made more sense for us to handle form state management ourselves.

  2. (in contrast to the increased boilerplate and more significant shift in mental models that would've been required if we'd used a more cross-cutting, opinionated library like Redux)

  3. To be completely fair, it's true that it's possible to achieve similar behavior in vanilla React — by aggressively splitting contexts, memoizing everything, etc. But at that point, why not just use a tool that was built for the job, that enables this behavior with better ergonomics and less code?

Footnote