React Rendering and Internal Working: A Detailed Exploration

React, a popular JavaScript library for building user interfaces, is renowned for its efficiency in rendering changes and declarative programming style. At the core of React’s performance lies its rendering mechanisms, reconciliation process, and state management. This blog will delve into how React rendering works, explore its internal workings, and discuss strategies for performance improvement.

Understanding React Rendering

React needs to render the components even before its displayed on the browser screen. The process of translating React components (written in JSX) into UI updates on screen is called rendering.

There are three steps to it:

  1. Triggering a render

  2. Rendering the component

  3. Committing to the DOM

Render Phase:

There are two reasons for a component to render:

  • Initial Render: When React components are mounted for the first time, the Virtual DOM is created and compared against an empty previous state. React starts with the root component and recursively renders all components within its tree until it knows what the initial UI should look like.

  • Re-render: Triggered by changes in state or props, React updates the UI by re-executing the rendering process, but with optimizations to minimize actual DOM manipulations. React identifies the specific component whose state update caused the render. It will start rendering from there and recursively traverse its child components if necessary.

Reconciliation Phase: Recursive Nature of Rendering

Rendering in React is a recursive process. This recursive process allows React to isolate updates and only re-render the parts of the UI that are affected.

  1. Component Call: React calls the component (a function or class) to evaluate its return value (usually JSX).

  2. Child Component Handling: If the component returns another component, React proceeds to call that child component. This process repeats, traversing through all components until there are no more nested components.

  3. Render Completion: Once React has resolved all nested components, it knows what the entire Virtual DOM tree should look like. React compares this new tree with the previous Virtual DOM tree to determine the minimal set of changes required.

The Virtual DOM (VDOM) is a lightweight, in-memory representation of the actual DOM. Instead of interacting directly with the real DOM, React operates on the Virtual DOM, which is faster to manipulate.

Commit Phase: Committing changes to DOM

After React has completed the rendering phase by calling your components and determining the desired UI structure, it moves to the commit phase, where changes are applied to the actual DOM.

  1. Initial Render: Using appendChild to Build the DOM

For the very first render:

  • React creates all the required DOM nodes by traversing the Virtual DOM tree.

  • Once all the nodes are created, React uses the appendChild() API to attach these nodes to the real DOM.

  1. Re-render: Applying Minimal Necessary Changes

For subsequent renders:

  • React compares the new Virtual DOM with the previous Virtual DOM (using its diffing algorithm) to identify what has changed.

  • It calculates a patch: the minimal set of DOM operations required to update the UI.

Instead of rebuilding the entire DOM tree, React selectively updates only the parts that differ between the new and old Virtual DOM.

  1. Optimised DOM operations:

React uses operations like:

  • setAttribute(): To update attributes that have changed.

  • removeChild(): To remove elements that are no longer needed.

  • insertBefore(): To reorder elements in a list.

The Reconciliation Process

Reconciliation is React’s process of updating the user interface efficiently. It is driven by React’s diffing algorithm, which minimizes the number of changes applied to the real DOM.

Key Concepts in React Rendering and Optimization

  1. Keys

    Keys help React efficiently identify and manage dynamic lists. A stable and unique key ensures that React avoids unnecessary unmounting and remounting of components.

  2. Component Types

React treats updates differently based on component type:

  • Functional Components: React re-executes the function to compute the new output.

  • Class Components: React calls render() and compares the result.

  • Pure Components: React skips rendering if the props and state are shallowly equal.

3. Type Comparison

  • Same Type: React updates the DOM node with new attributes and properties.

  • Different Type: React unmounts the old component and mounts a new one.

4. Shallow vs. Deep Comparison

  • Shallow Comparison: React compares the references of props and state objects, rather than deeply comparing their values.

  • Deep Comparison: Not used by default due to computational expense. Developers can implement custom comparison logic using shouldComponentUpdate or React.memo.

Heuristics for Efficient Reconciliation: Diffing Algorithm

Elements of Different Types:

When the ‘type’ of a root element changes, React tears down the old tree and creates a completely new tree.

What Triggers a Full Rebuild?

React decides to tear down the tree if the type of the root element changes. This includes:

  • Switching between different HTML tags (e.g., <a><img>).

  • Switching between different components (e.g., <Article><Comment>).

  • Switching between a component and a native element (e.g., <Button><div>).

Why Does This Happen?

React identifies elements by their type. If the type changes, React assumes the new element is unrelated to the old one. Instead of attempting to reconcile them, React:

  1. Unmounts the old tree, destroying all associated DOM nodes and component instances.

  2. Builds a new tree from scratch, creating new DOM nodes and initializing new component instances.

Example: Root Type Change

function App({ type }) {
    if (type === "link") {
        return <a href="#">Click me</a>; // Root is <a>
    } else {
        return <button>Click me</button>; // Root is <button>
    }
}

Behavior on Type Change

Initial Render:

  • type="link" renders <a href="#">Click me</a>.

State Change:

  • Changing type to "button" replaces the <a> element with a <button> element.

  • React tears down the <a> tree, unmounts all child components, and builds a new <button> tree.

When to Embrace Full Rebuilds

While React’s full rebuild mechanism can seem costly, it ensures a clean slate when transitioning between unrelated UI states. This can be useful for:

  • Completely replacing UI layouts (e.g., switching from a login form to a dashboard).

  • Ensuring all components are re-initialized with new data.

DOM Elements of the Same Type

When React compares two elements of the same type, it updates only the attributes and properties that have changed. This minimizes the work required to update the real DOM.

How React Handles DOM Elements of the Same Type

Attribute Comparison

React compares the attributes of the old and new elements:

  1. Identical Attributes: React does nothing if an attribute’s value has not changed.

  2. Changed Attributes: React updates only the attributes that have changed.

// Initial render
<div className="box" id="unique"></div>

// Update
<div className="container" id="unique"></div>
  • React notices that the className attribute has changed from "box" to "container".

  • It updates only the className attribute on the existing <div> DOM node.

  • Since the id remains unchanged, React leaves it as is.

3. Style Updates: When updating the style attribute, React performs a property-by-property comparison:

  • It updates only the properties that have changed.

  • Unchanged properties remain intact.

  • Removed properties are explicitly unset.

/ Initial render
<div style={{ color: "red", fontSize: "16px" }}></div>

// Update
<div style={{ color: "blue", fontWeight: "bold" }}></div>

React detects:

  • color has changed from "red" to "blue".

  • fontWeight is a new property and is added.

  • fontSize is no longer present and is removed.

The result is a minimal update:

  • color is updated.

  • fontWeight is added.

  • fontSize is removed.

4. Child Reconciliation: After handling the attributes of the DOM node, React recursively updates the element’s children:

5. Child Elements of the Same Type: React applies the same process (attribute and property comparison).

6. Child Elements of a Different Type:

  • React unmounts the old child and mounts the new one.

  • Lists of Children: React uses keys to determine which child elements have been added, removed, or reordered.

Shallow Comparison vs. Deep Comparison

React uses shallow comparison by default when reconciling the virtual DOM. This approach only compares the first-level properties of objects or elements in the virtual DOM. Specifically, it checks whether the reference to an object or a value has changed, rather than comparing the deep structure of the objects.

How Shallow Comparison Works:

  • React compares props and state of the component using reference equality (i.e., whether the reference to the object has changed).

  • For objects or arrays passed as props, React does not compare their internal properties or values deeply.

  • Instead, it checks if the reference to the object has changed.

  • For example, if you pass an object prop to a component, React only checks if the reference to the object is different, rather than comparing the properties of the object itself.

How Deep Comparison Works:

  • It recursively checks the equality of each field or property of an object or array.

  • If an object is passed as a prop, React will compare all nested properties (not just the reference), checking for any changes at all levels of the object.

const Component = ({ data }) => {
  return <div>{data.value}</div>;
};

//shallow comparison
const prevProps = { data: { value: 1 } };
const nextProps = { data: { value: 1 } }; // Same value but different reference

// deep comparison
const prevProps = { data: { value: 1, nested: { prop: 5 } } };
const nextProps = { data: { value: 1, nested: { prop: 6 } } }; // Same reference, but deep change

// React will perform a shallow comparison on 'data'.
// Since the reference of 'data' did not change, React will not detect the change in the 'prop' property, 
// and no re-render will occur unless the parent component itself is updated.

How to Trigger Reconciliation for Nested Object Changes

If you need React to detect changes to the internal properties of nested objects, you’ll need to ensure that the reference to the object changes. This can be done in a few ways:

  • Create a New Object: You can create a new object (or array) when you change a nested value, which will update the reference. This is the key to triggering a re-render.
const nextProps = { ...prevProps, prop: 6 }; // New object, reference changed

//  React will detect a change in the reference and will trigger reconciliation, because the reference to the user object has changed.
  • Use State Updaters: If you’re working with useState in React, you can use the state updater function to create a new object, ensuring the reference changes.
setProps((prevProp) => ({ ...prevProp, prop: 6 }));
  • Immutable Data Patterns: In React (and in JavaScript in general), using immutable data patterns is a common practice. This involves creating a new object (or array) every time you need to change a value. Immutable updates allow React to detect changes because the reference to the object is always different.

Understanding React’s Handling of Object Updates: Full Object vs. Single Key Update

1. Updating the Reference to a New Object (Full Object Update)

What Happens?

When you create a completely new object and update the state with it:

  • React notices that the reference of the object has completely changed.

  • This triggers a re-render because React assumes the entire object is different, regardless of the internal values.

const [user, setUser] = useState({ name: 'John', age: 30 });

// Creating a completely new object
setUser({ name: 'Jane', age: 30 });

// Old Object: { name: 'John', age: 30 } (reference 0x1234)
// New Object: { name: 'Jane', age: 30 } (reference 0x5678)

React’s Behavior

  • React detects that the reference of user has changed (0x1234 vs 0x5678)). Previous reference: { name: 'John', age: 30 }. New reference: { name: 'Jane', age: 30 }.

  • React re-renders the component associated with user.

  • The Virtual DOM is updated with the new object.

  • React compares the old and new Virtual DOM trees and updates the actual DOM accordingly.

2. Updating the Reference with Only One Key’s Value (Shallow Update)

What Happens?

When you update a single key’s value while creating a new object reference (using methods like the spread operator):

  • React detects a new reference for the object and triggers a re-render.

  • The DOM is updated minimally based on the actual changes.

const [user, setUser] = useState({ name: 'John', age: 30 });

// Updating a single key while creating a new object
setUser((prevUser) => ({ ...prevUser, name: 'Jane' }));

// Old Object: { name: 'John', age: 30 } (reference 0x1234)
// New Object: { name: 'Jane', age: 30 } (reference 0x5678)

React’s Behavior

  • A new object reference is created: { name: 'Jane', age: 30 } Previous reference: { name: 'John', age: 30 } New reference: { name: 'Jane', age: 30 }.

  • When you spread prevUser, React creates a new object with the new reference 0x5678, even though age has not changed and name is the only value updated.

  • Because the reference changes (0x1234 to 0x5678), React triggers a re-render.

  • The Virtual DOM is updated to reflect only the changes in name.

  • React applies the minimal necessary updates to the real DOM (e.g., updating the text for name).

The Difference in Behavior:

  1. In both cases, the reference to the object changes (0x1234 → 0x5678), which causes React to trigger a re-render.

  2. When using spread operator, you’re preserving the inner references for values that haven’t changed (age remains unchanged, so React doesn’t care about it), but you’re still creating a new object reference for the parent object, which triggers a re-render.

  3. When creating a completely new object (setUser({ name: ‘Jane’ })), React sees a completely new reference and triggers a re-render too.

3. Nested Object Update Without Changing Reference

What Happens?

If you modify a value inside a nested object but do not change the reference of the parent object:

  1. React will not detect any change because the object reference remains the same.

  2. This means no re-render will occur.

const [user, setUser] = useState({ name: 'John', age: 30 });

// Modifying a value directly without changing the reference
setUser((prevUser) => {
    prevUser.name = 'Jane'; // Mutating the object
    return prevUser;
});

React’s Behavior

  • The reference of user remains unchanged.

  • React does not detect any change during its shallow comparison of the references.

  • The Virtual DOM and the real DOM are not updated.

Which is more expensive?

Direct object update (setUser({ name: ‘Jane’, age: 30 }))

  • New object creation: You’re creating a completely new object with the reference 0x5678. The previous object reference 0x1234 is discarded.

  • Shallow comparison: React performs a shallow comparison between the old object (0x1234) and the new object (0x5678), which are completely different references.

  • Re-render triggers: Since the references differ, React will re-render any component that uses user. If user is passed down to child components, React will also re-render those components (depending on the component structure and memoization).

  • Cost: The expense here is high, as React has to update the entire object and possibly many components, depending on how user is used in your app. If the object is large, React will compare the entire object and check the new state in the virtual DOM.

Spread operator (setUser(prevUser => ({ …prevUser, name: ‘Jane’ })))

  • New object creation: Even though you’re only updating name, React still creates a new object reference 0x5678, which contains all the same values as the previous object (0x1234) except for the updated name.

  • Shallow comparison: React performs a shallow comparison between the previous object (0x1234) and the new object (0x5678). React sees that the references are different, so it triggers a re-render.

  • Re-render triggers: React will re-render components that depend on the user state. If the object is large or there are many child components consuming this state, React will potentially trigger re-renders in all of them.

  • Cost: While only one property (name) changes, React still creates a new object reference, and the expense of re-rendering is similar to Case 1.

Thoughts :

In both cases, React re-renders components when the object reference changes.

Case 1 (directly updating the entire object) might have a slightly higher re-render cost because a new object reference is created from scratch, potentially affecting many components.

Case 2 (using the spread operator) is usually less expensive in terms of operations since you are only modifying one property, but you still create a new object reference, which React will detect and may cause child component re-renders.

Factors That Impact Re-render Expense:

  1. Component Structure: The expense is higher if the updated object (user) is passed to many child components. React needs to check whether any of those child components need to re-render.

  2. Object Size and Complexity: The larger the object, the more React has to compare in the virtual DOM to figure out the differences.

  3. State Dependencies: If the state is used by deeply nested components, React will go through those components and check if the state changes warrant a re-render. This can add to the re-render cost.

  4. Memoization: If the components that rely on this state are wrapped in React.memo or use useMemo, React can avoid unnecessary re-renders when the state hasn’t truly changed (based on shallow comparison).

  5. Use of key Prop: When rendering lists of elements, if the key prop changes (even for deeply nested elements), React will treat each element as a new one and re-render it completely, which can be expensive if not optimized.

Optimization Techniques

1. Memoization

  • Use React.memo for functional components to prevent unnecessary renders.

  • Wrap expensive calculations with useMemo for value caching and useCallback for function caching.

const MemoizedComponent = React.memo(({ value }) => <div>{value}</div>);

2. Proper Key Usage

Avoid using array indices as keys unless the list is static and will not change.

When React updates a list:

  • If the key matches an existing element, React updates that element in place.

  • If the key is new, React creates a new element.

  • If the key is missing, React removes the corresponding element.

3. Immutable Updates

Always create new objects or arrays instead of mutating existing ones to trigger React’s reconciliation effectively.


setUser((prevUser) => ({ ...prevUser, name: 'Jane' }));

4. Batch Updates

React batches state updates in event handlers for performance. This reduces the number of re-renders.

handleChange = (newValue) => {
    this.setState({ value1: newValue });
    this.setState({ value2: newValue }); // Both updates are batched
};

5. Avoid Re-renders with shouldComponentUpdate

Implement shouldComponentUpdate in class components or use React.memo to prevent updates when props or state remain the same.

Concurrent Rendering and React’s Modern Enhancements

React’s Concurrent Mode introduces features that enable rendering to be interruptible and adaptable to user interactions. This ensures a responsive UI, even during heavy updates.

  • Prioritized Rendering: Allows React to prioritize urgent tasks, such as user input, over less critical rendering.

  • Pausing and Resuming: React can pause work and resume later without starting over.

Conclusion:

React’s rendering and reconciliation mechanisms are at the heart of its performance capabilities. Leveraging features like memoization, batching updates, and immutable patterns ensures that your applications remain responsive and performant.

As React evolves, features like Concurrent Mode further enhance its ability to handle complex applications while maintaining excellent user experiences.

References:

https://react.dev/learn/preserving-and-resetting-state

https://react.dev/learn/understanding-your-ui-as-a-tree

https://react.dev/learn/render-and-commit

https://legacy.reactjs.org/docs/reconciliation.html