Tech News
← Back to articles

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

read original related products more articles

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.

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.

Open on CodeSandbox Open Sandbox App.js clinical-trial-tree.js use-flash-on-render.js 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 < CompletedCount /> { clinicalTrialTree . map ( ( node ) => ( < Node key = { node . id } node = { node } /> ) ) } ) } export function CompletedCount ( ) { const ref = useFlashOnRender ( ) const [ completedNodeIds ] = useContext ( CompletedNodeIdsContext ) const completedCount = completedNodeIds . size return ( < p ref = { ref } > Completed Count: < strong > { completedCount } ) } 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 > — { node . id } { ' ' } { node . type === 'field' && ( isCompleted ? ( < span onClick = { ( ) => setCompletedNodeIds ( ( prev ) => { prev . delete ( node . id ) return new Set ( [ ... prev ] ) } ) } > ✅ ) : ( < button onClick = { ( ) => setCompletedNodeIds ( ( prev ) => new Set ( [ ... prev , node . id ] ) ) } > Mark Completed ) ) } < div style = { { paddingLeft : '20px' } } > { node . children . map ( ( child ) => ( < Node key = { child . id } node = { child } /> ) ) } ) }

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

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. Nodes sometimes need to be conditionally visible or validated based on the state of other nodes. 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:

Do we use "controlled" inputs and bring all state into React or take an "uncontrolled" approach and grab values on submit? 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? 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

... continue reading