React ⚛️
June 12

React. Nodes update and memoization

During the development of modern web applications, performance often becomes one of the key aspects that concern both developers and users. Users expect lightning-fast responsiveness, and developers strive to create applications that work quickly and efficiently.

One powerful tool that enables high performance in React applications is memoization. Memoization helps significantly reduce the amount of computations and, consequently, interface updates, which positively impacts the overall speed and responsiveness of the application.

In this article, we will delve "under the hood" of the React engine and see how node updates occur. At the same time, we will also explore the basic principles of memoization and its application in different types of components.

What is memoization?

Let's start with the theory. Formally, memoization can be defined as an optimization technique used to increase the performance of programs by storing the results of function calls and returning the saved result for subsequent calls with the same arguments.

In the context of React, memoization is especially useful for preventing unnecessary redraws and improving the performance of applications. In other words, it helps avoid unnecessary updates of components, making the application more reactive and efficient. Another equally important property of memoization is reducing the number of expensive computations. Instead of performing them on every component render, you can "remember the result" and recalculate it only in case of changes in the corresponding input parameters.

Where exactly is the memoized result stored?

To answer this question, we need to introduce the concept of React Fiber. I extensively described this structure in the article In-Depth React: Reconciliation, Renders, Fiber, Virtual Tree. In short, Fiber is an abstract entity in the React engine that describes any processed React node, whether it's a component, hook, or host root. Fiber has properties like pendingProps, memoizedProps, and memoizedState, among others. It is in these properties that the memoized result is stored. How exactly? Let's find out.

Memoized Components

Although the principles of memoization in React are the same for all entities, the implementation details may vary depending on the type of Fiber. To understand the process, we need to go through each type separately. Let's start with components, which are the "oldest" structure in React.

Since the early versions of React, we have been used to distinguishing components into class and functional components. In reality, there are many more component types inside the engine. The component type corresponds to the Fiber type and is specified in the Fiber.tag property. In the mentioned article, I provided a full list of types for React version 18.2, which included a total of 28 types. In version 18.3.1, the list has slightly changed, now totaling 26 types.

export const FunctionComponent = 0;
export const ClassComponent = 1;
export const IndeterminateComponent = 2; // Before we know whether it is function or class
export const HostRoot = 3; // Root of a host tree. Could be nested inside another node.
export const HostPortal = 4; // A subtree. Could be an entry point to a different renderer.
export const HostComponent = 5;
export const HostText = 6;
export const Fragment = 7;
export const Mode = 8;
export const ContextConsumer = 9;
export const ContextProvider = 10;
export const ForwardRef = 11;
export const Profiler = 12;
export const SuspenseComponent = 13;
export const MemoComponent = 14;
export const SimpleMemoComponent = 15;
export const LazyComponent = 16;
export const IncompleteClassComponent = 17;
export const DehydratedFragment = 18;
export const SuspenseListComponent = 19;
export const ScopeComponent = 21;
export const OffscreenComponent = 22;
export const LegacyHiddenComponent = 23;
export const CacheComponent = 24;
export const TracingMarkerComponent = 25;

To understand the memoization processes, we only need three items from this list:

export const FunctionComponent = 0;
export const ClassComponent = 1;
export const MemoComponent = 14;

When does memoization occur?

Before delving into the Fiber types directly, it is worth understanding at which specific moment the memoization process is triggered. In the previous article, I outlined four processing phases for a node. The first phase, begin, determines the Fiber type that will initiate the necessary lifecycle. Nodes that have not yet been mounted have types like IndeterminateComponent, LazyComponent, or IncompleteClassComponent. For such Fibers, the mounting process will be initiated. In other cases, the reconciler will attempt to update the existing node. To be more specific, the decision tree is processed by the reconciler's beginWork function.

/packages/react-reconciler/src/ReactFiberBeginWork.new.js#L3699

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  ...
  
  switch (workInProgress.tag) {
    case IndeterminateComponent: {
      return mountIndeterminateComponent(
        current,
        workInProgress,
        workInProgress.type,
        renderLanes,
      );
    }
    case LazyComponent: {
      const elementType = workInProgress.elementType;
      return mountLazyComponent(
        current,
        workInProgress,
        elementType,
        renderLanes,
      );
    }
    case FunctionComponent: {
      const Component = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps =
        workInProgress.elementType === Component
          ? unresolvedProps
          : resolveDefaultProps(Component, unresolvedProps);
      return updateFunctionComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderLanes,
      );
    }
    case ClassComponent: {
      const Component = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps =
        workInProgress.elementType === Component
          ? unresolvedProps
          : resolveDefaultProps(Component, unresolvedProps);
      return updateClassComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderLanes,
      );
    }
    case HostRoot:
      return updateHostRoot(current, workInProgress, renderLanes);
    case HostComponent:
      return updateHostComponent(current, workInProgress, renderLanes);
    case HostText:
      return updateHostText(current, workInProgress);
    case SuspenseComponent:
      return updateSuspenseComponent(current, workInProgress, renderLanes);
    case HostPortal:
      return updatePortalComponent(current, workInProgress, renderLanes);
    case ForwardRef: {
      const type = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps =
        workInProgress.elementType === type
          ? unresolvedProps
          : resolveDefaultProps(type, unresolvedProps);
      return updateForwardRef(
        current,
        workInProgress,
        type,
        resolvedProps,
        renderLanes,
      );
    }
    case Fragment:
      return updateFragment(current, workInProgress, renderLanes);
    case Mode:
      return updateMode(current, workInProgress, renderLanes);
    case Profiler:
      return updateProfiler(current, workInProgress, renderLanes);
    case ContextProvider:
      return updateContextProvider(current, workInProgress, renderLanes);
    case ContextConsumer:
      return updateContextConsumer(current, workInProgress, renderLanes);
    case MemoComponent: {
      const type = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      // Resolve outer props first, then resolve inner props.
      let resolvedProps = resolveDefaultProps(type, unresolvedProps);
      resolvedProps = resolveDefaultProps(type.type, resolvedProps);
      return updateMemoComponent(
        current,
        workInProgress,
        type,
        resolvedProps,
        renderLanes,
      );
    }
    case SimpleMemoComponent: {
      return updateSimpleMemoComponent(
        current,
        workInProgress,
        workInProgress.type,
        workInProgress.pendingProps,
        renderLanes,
      );
    }
    case IncompleteClassComponent: {
      const Component = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps =
        workInProgress.elementType === Component
          ? unresolvedProps
          : resolveDefaultProps(Component, unresolvedProps);
      return mountIncompleteClassComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderLanes,
      );
    }
    case SuspenseListComponent: {
      return updateSuspenseListComponent(current, workInProgress, renderLanes);
    }
    case ScopeComponent: {
      if (enableScopeAPI) {
        return updateScopeComponent(current, workInProgress, renderLanes);
      }
      break;
    }
    case OffscreenComponent: {
      return updateOffscreenComponent(current, workInProgress, renderLanes);
    }
    case LegacyHiddenComponent: {
      if (enableLegacyHidden) {
        return updateLegacyHiddenComponent(
          current,
          workInProgress,
          renderLanes,
        );
      }
      break;
    }
    case CacheComponent: {
      if (enableCache) {
        return updateCacheComponent(current, workInProgress, renderLanes);
      }
      break;
    }
    case TracingMarkerComponent: {
      if (enableTransitionTracing) {
        return updateTracingMarkerComponent(
          current,
          workInProgress,
          renderLanes,
        );
      }
      break;
    }
  }
  
  ...
}

From the memoization perspective, whether a component is mounted or updated, there is not a significant difference. Ultimately, the corresponding component update methods will be called regardless. The only distinction lies in whether the previous property values, state, or dependencies are passed into these methods. I will leave the process of transforming mountable types into updatable ones for future publications and suggest moving directly to the specific node update methods.

ClassComponent

The name indicates that this type is assigned to class components. Just in case, let's recall how a class component is created.

class MyComponent extends React.Component<{ prop1: string }> {
  render() {
    return (
      <div>
        {this.props.prop1}
      </div>
    );
  }
}

In the provided beginWork, the update of class components begins as follows.

const Component = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
const resolvedProps =
  workInProgress.elementType === Component
    ? unresolvedProps
    : resolveDefaultProps(Component, unresolvedProps);
return updateClassComponent(
  current,
  workInProgress,
  Component,
  resolvedProps,
  renderLanes,
);

The first thing that catches the eye here is the constant resolvedProps. Despite the 5 lines of code, this constant simply takes the Fiber.pendingProps property, which is nothing but an object with the new component properties. To make it clear, I will also provide the code of the resolveDefaultProps function.

/packages/react-reconciler/src/ReactFiberLazyComponent.new.js#L12

export function resolveDefaultProps(Component: any, baseProps: Object): Object {
  if (Component && Component.defaultProps) {
    // Resolve default props. Taken from ReactElement
    const props = assign({}, baseProps);
    const defaultProps = Component.defaultProps;
    for (const propName in defaultProps) {
      if (props[propName] === undefined) {
        props[propName] = defaultProps[propName];
      }
    }
    return props;
  }
  return baseProps;
}

In more human terms, this function simply assigns default values to properties if they were specified when creating the component.

class MyComponent extends React.Component<{ color?: string }> {
  static defaultProps = {
    color: 'blue',
  };

  render() {
    return (
      <div>
        {this.props.color}
      </div>
    );
  }
}

<MyComponent />              // <div>blue</div>
<MyComponent color="red" />  // <div>red</div>

The update mechanism itself is moved to the updateClassComponent function. I will provide its code in a shortened form, as part of the functionality deals with processing the context provider and some service operations for dev mode.

/packages/react-reconciler/src/ReactFiberBeginWork.new.js#L1062

function updateClassComponent(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  nextProps: any,
  renderLanes: Lanes,
) {
  ...
  const instance = workInProgress.stateNode;
  let shouldUpdate;
  if (instance === null) {
    resetSuspendedCurrentOnMountInLegacyMode(current, workInProgress);
    
    // In the initial pass we might need to construct the instance.
    constructClassInstance(workInProgress, Component, nextProps);
    mountClassInstance(workInProgress, Component, nextProps, renderLanes);
    shouldUpdate = true;
  } else if (current === null) {
    // In a resume, we'll already have an instance we can reuse.
    shouldUpdate = resumeMountClassInstance(
      workInProgress,
      Component,
      nextProps,
      renderLanes,
    );
  } else {
    shouldUpdate = updateClassInstance(
      current,
      workInProgress,
      Component,
      nextProps,
      renderLanes,
    );
  }
  const nextUnitOfWork = finishClassComponent(
    current,
    workInProgress,
    Component,
    shouldUpdate,
    hasContext,
    renderLanes,
  );
  ...
  return nextUnitOfWork;
}

The first two "if" statements here are responsible for the process of mounting a new component. As mentioned earlier, we will not delve into these processes separately because ultimately the cycle will come to the update function where all the memoization work will take place. Specifically, it happens in the updateClassInstance function, which I will present in its entirety.

/packages/react-reconciler/src/ReactFiberClassComponent.new.js#L1123

// Invokes the update life-cycles and returns false if it shouldn't rerender.
function updateClassInstance(
  current: Fiber,
  workInProgress: Fiber,
  ctor: any,
  newProps: any,
  renderLanes: Lanes,
): boolean {
  const instance = workInProgress.stateNode;
  
  cloneUpdateQueue(current, workInProgress);
  
  const unresolvedOldProps = workInProgress.memoizedProps;
  const oldProps =
    workInProgress.type === workInProgress.elementType
      ? unresolvedOldProps
      : resolveDefaultProps(workInProgress.type, unresolvedOldProps);
  instance.props = oldProps;
  const unresolvedNewProps = workInProgress.pendingProps;
  
  const oldContext = instance.context;
  const contextType = ctor.contextType;
  let nextContext = emptyContextObject;
  if (typeof contextType === 'object' && contextType !== null) {
    nextContext = readContext(contextType);
  } else if (!disableLegacyContext) {
    const nextUnmaskedContext = getUnmaskedContext(workInProgress, ctor, true);
    nextContext = getMaskedContext(workInProgress, nextUnmaskedContext);
  }
  
  const getDerivedStateFromProps = ctor.getDerivedStateFromProps;
  const hasNewLifecycles =
    typeof getDerivedStateFromProps === 'function' ||
    typeof instance.getSnapshotBeforeUpdate === 'function';
    
  // Note: During these life-cycles, instance.props/instance.state are what
  // ever the previously attempted to render - not the "current". However,
  // during componentDidUpdate we pass the "current" props.
  
  // In order to support react-lifecycles-compat polyfilled components,
  // Unsafe lifecycles should not be invoked for components using the new APIs.
  if (
    !hasNewLifecycles &&
    (typeof instance.UNSAFE_componentWillReceiveProps === 'function' ||
      typeof instance.componentWillReceiveProps === 'function')
  ) {
    if (
      unresolvedOldProps !== unresolvedNewProps ||
      oldContext !== nextContext
    ) {
      callComponentWillReceiveProps(
        workInProgress,
        instance,
        newProps,
        nextContext,
      );
    }
  }
  
  resetHasForceUpdateBeforeProcessing();
  
  const oldState = workInProgress.memoizedState;
  let newState = (instance.state = oldState);
  processUpdateQueue(workInProgress, newProps, instance, renderLanes);
  newState = workInProgress.memoizedState;
  
  if (
    unresolvedOldProps === unresolvedNewProps &&
    oldState === newState &&
    !hasContextChanged() &&
    !checkHasForceUpdateAfterProcessing() &&
    !(
      enableLazyContextPropagation &&
      current !== null &&
      current.dependencies !== null &&
      checkIfContextChanged(current.dependencies)
    )
  ) {
    // If an update was already in progress, we should schedule an Update
    // effect even though we're bailing out, so that cWU/cDU are called.
    if (typeof instance.componentDidUpdate === 'function') {
      if (
        unresolvedOldProps !== current.memoizedProps ||
        oldState !== current.memoizedState
      ) {
        workInProgress.flags |= Update;
      }
    }
    if (typeof instance.getSnapshotBeforeUpdate === 'function') {
      if (
        unresolvedOldProps !== current.memoizedProps ||
        oldState !== current.memoizedState
      ) {
        workInProgress.flags |= Snapshot;
      }
    }
    return false;
  }
  
  if (typeof getDerivedStateFromProps === 'function') {
    applyDerivedStateFromProps(
      workInProgress,
      ctor,
      getDerivedStateFromProps,
      newProps,
    );
    newState = workInProgress.memoizedState;
  }
  
  const shouldUpdate =
    checkHasForceUpdateAfterProcessing() ||
    checkShouldComponentUpdate(
      workInProgress,
      ctor,
      oldProps,
      newProps,
      oldState,
      newState,
      nextContext,
    ) ||
    // TODO: In some cases, we'll end up checking if context has changed twice,
    // both before and after `shouldComponentUpdate` has been called. Not ideal,
    // but I'm loath to refactor this function. This only happens for memoized
    // components so it's not that common.
    (enableLazyContextPropagation &&
      current !== null &&
      current.dependencies !== null &&
      checkIfContextChanged(current.dependencies));
      
  if (shouldUpdate) {
    // In order to support react-lifecycles-compat polyfilled components,
    // Unsafe lifecycles should not be invoked for components using the new APIs.
    if (
      !hasNewLifecycles &&
      (typeof instance.UNSAFE_componentWillUpdate === 'function' ||
        typeof instance.componentWillUpdate === 'function')
    ) {
      if (typeof instance.componentWillUpdate === 'function') {
        instance.componentWillUpdate(newProps, newState, nextContext);
      }
      if (typeof instance.UNSAFE_componentWillUpdate === 'function') {
        instance.UNSAFE_componentWillUpdate(newProps, newState, nextContext);
      }
    }
    if (typeof instance.componentDidUpdate === 'function') {
      workInProgress.flags |= Update;
    }
    if (typeof instance.getSnapshotBeforeUpdate === 'function') {
      workInProgress.flags |= Snapshot;
    }
  } else {
    // If an update was already in progress, we should schedule an Update
    // effect even though we're bailing out, so that cWU/cDU are called.
    if (typeof instance.componentDidUpdate === 'function') {
      if (
        unresolvedOldProps !== current.memoizedProps ||
        oldState !== current.memoizedState
      ) {
        workInProgress.flags |= Update;
      }
    }
    if (typeof instance.getSnapshotBeforeUpdate === 'function') {
      if (
        unresolvedOldProps !== current.memoizedProps ||
        oldState !== current.memoizedState
      ) {
        workInProgress.flags |= Snapshot;
      }
    }
    
    // If shouldComponentUpdate returned false, we should still update the
    // memoized props/state to indicate that this work can be reused.
    workInProgress.memoizedProps = newProps;
    workInProgress.memoizedState = newState;
  }
  
  // Update the existing instance's state, props, and context pointers even
  // if shouldComponentUpdate returns false.
  instance.props = newProps;
  instance.state = newState;
  instance.context = nextContext;
  
  return shouldUpdate;
}

It may look quite cumbersome, but that's how it is. This is where all the lifecycle magic of class components happens. Let's go through it step by step.

const unresolvedOldProps = workInProgress.memoizedProps;
const oldProps =
  workInProgress.type === workInProgress.elementType
    ? unresolvedOldProps
    : resolveDefaultProps(workInProgress.type, unresolvedOldProps);
instance.props = oldProps;
const unresolvedNewProps = workInProgress.pendingProps;

We have already seen what this is about. The essence of this code is to obtain two constants: unresolvedOldProps, which is immediately assigned to this.props of the component instance, and unresolvedNewProps. These constants will be involved in lifecycle methods and memoization, among other things.

const oldContext = instance.context;
const contextType = ctor.contextType;
let nextContext = emptyContextObject;
if (typeof contextType === 'object' && contextType !== null) {
  nextContext = readContext(contextType);
} else if (!disableLegacyContext) {
  const nextUnmaskedContext = getUnmaskedContext(workInProgress, ctor, true);
  nextContext = getMaskedContext(workInProgress, nextUnmaskedContext);
} 

The next block is responsible for resolving the attached context of the component. We won't delve into this part in detail here, but variables like oldContext and nextContext participate in lifecycle methods alongside unresolvedOldProps and unresolvedNewProps, so it is worth noting them.

const getDerivedStateFromProps = ctor.getDerivedStateFromProps;
const hasNewLifecycles =
  typeof getDerivedStateFromProps === 'function' ||
  typeof instance.getSnapshotBeforeUpdate === 'function';
  
// Note: During these life-cycles, instance.props/instance.state are what
// ever the previously attempted to render - not the "current". However,
// during componentDidUpdate we pass the "current" props.

// In order to support react-lifecycles-compat polyfilled components,
// Unsafe lifecycles should not be invoked for components using the new APIs.
if (
  !hasNewLifecycles &&
  (typeof instance.UNSAFE_componentWillReceiveProps === 'function' ||
    typeof instance.componentWillReceiveProps === 'function')
) {
  if (
    unresolvedOldProps !== unresolvedNewProps ||
    oldContext !== nextContext
  ) {
    callComponentWillReceiveProps(
      workInProgress,
      instance,
      newProps,
      nextContext,
    );
  }
}

Another block that I cannot overlook is the one before proceeding to the next stages of the component's lifecycle. It is necessary to first execute the componentWillReceiveProps(nextProps) method if it has been defined in the component. An interesting point here is that this method will only be called if the component does not use the new API, i.e., if it does not have the methods getDerivedStateFromProps(props, state) and getSnapshotBeforeUpdate(prevProps, prevState).

const oldState = workInProgress.memoizedState;
let newState = (instance.state = oldState);
processUpdateQueue(workInProgress, newProps, instance, renderLanes);
newState = workInProgress.memoizedState;

Lastly, the fourth block to consider is the variables oldState and newState, which also play a role in the component's lifecycle. Let's dive a bit deeper into this. While oldState is straightforward and retrieved from Fiber.memoizedState, newState requires further explanation.

By default, newState is assigned the value of the current oldState. Next, the updateQueue loop is executed, triggered by the call to processUpdateQueue. I won't provide the code for this function here as it is quite extensive and variable, which would only distract us from the article's main topic. The key point is to understand that this function eventually generates a new State and writes it to workInProgress.memoizedState.

/packages/react-reconciler/src/ReactFiberClassUpdateQueue.new.js#L458

export function processUpdateQueue<State>(
  ...
  // These values may change as we process the queue.
  if (firstBaseUpdate !== null) {
    ...
    workInProgress.memoizedState = newState;
  }
}

After that, we obtain our variable newState in our update function.

This concludes the gathering of essential information about the component, and the actual updating process begins.

if (
  unresolvedOldProps === unresolvedNewProps &&
  oldState === newState &&
  !hasContextChanged() &&
  !checkHasForceUpdateAfterProcessing() &&
  !(
    enableLazyContextPropagation &&
    current !== null &&
    current.dependencies !== null &&
    checkIfContextChanged(current.dependencies)
  )
) {
  // If an update was already in progress, we should schedule an Update
  // effect even though we're bailing out, so that cWU/cDU are called.
  if (typeof instance.componentDidUpdate === 'function') {
    if (
      unresolvedOldProps !== current.memoizedProps ||
      oldState !== current.memoizedState
    ) {
      workInProgress.flags |= Update;
    }
  }
  if (typeof instance.getSnapshotBeforeUpdate === 'function') {
    if (
      unresolvedOldProps !== current.memoizedProps ||
      oldState !== current.memoizedState
    ) {
      workInProgress.flags |= Snapshot;
    }
  }
  return false;
}

No, this is not an update yet, as it may have initially appeared. Before proceeding with the update, the reconciler excludes unnecessary processing for optimization purposes. In this case, if the references to the component's properties and its state have not changed and forceUpdate() has not been called, there is no point in continuing the process, and the entire function stops.

if (typeof getDerivedStateFromProps === 'function') {
  applyDerivedStateFromProps(
    workInProgress,
    ctor,
    getDerivedStateFromProps,
    newProps,
  );
  newState = workInProgress.memoizedState;
}

Now, if something in the component's state has indeed changed, we can move on to its update. The first step is to call getDerivedStateFromProps(props, state) because it modifies the component's state and, consequently, the newState variable in the update function.

const shouldUpdate =
  checkHasForceUpdateAfterProcessing() ||
  checkShouldComponentUpdate(
    workInProgress,
    ctor,
    oldProps,
    newProps,
    oldState,
    newState,
    nextContext,
  ) ||
  // TODO: In some cases, we'll end up checking if context has changed twice,
  // both before and after `shouldComponentUpdate` has been called. Not ideal,
  // but I'm loath to refactor this function. This only happens for memoized
  // components so it's not that common.
  (enableLazyContextPropagation &&
    current !== null &&
    current.dependencies !== null &&
    checkIfContextChanged(current.dependencies));

Finally, all constants and variables have been gathered, and now a decision can be made regarding the further progression through the lifecycle, specifically, whether the component will be re-rendered. A component should only be re-rendered in two cases: if the values of properties, state, or context differ from the previous ones, or if forceUpdate() has been called. The last one is determined by the checkHasForceUpdateAfterProcessing() function, while the comparison of values occurs within the checkShouldComponentUpdate() function. Let's delve into this function in more detail.

/packages/react-reconciler/src/ReactFiberClassComponent.new.js#L305

function checkShouldComponentUpdate(
  workInProgress,
  ctor,
  oldProps,
  newProps,
  oldState,
  newState,
  nextContext,
) {
  const instance = workInProgress.stateNode;
  if (typeof instance.shouldComponentUpdate === 'function') {
    let shouldUpdate = instance.shouldComponentUpdate(
      newProps,
      newState,
      nextContext,
    );
    ...
    return shouldUpdate;
  }
  
  if (ctor.prototype && ctor.prototype.isPureReactComponent) {
    return (
      !shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState)
    );
  }
  
  return true;
}

If we remove the auxiliary dev-mode code from the function, it surprisingly turns out to be quite simple. The function encompasses just three scenarios:

  1. The component has a shouldComponentUpdate() method. In this case, the decision of whether the component should be re-rendered is entirely left to the developer.
  2. n the case of a PureComponent, a shallow comparison of properties and state is conducted (we will discuss this in more detail shortly).
  3. In all other cases, the component will be re-rendered anyway.
if (shouldUpdate) {
  // In order to support react-lifecycles-compat polyfilled components,
  // Unsafe lifecycles should not be invoked for components using the new APIs.
  if (
    !hasNewLifecycles &&
    (typeof instance.UNSAFE_componentWillUpdate === 'function' ||
      typeof instance.componentWillUpdate === 'function')
  ) {
    if (typeof instance.componentWillUpdate === 'function') {
      instance.componentWillUpdate(newProps, newState, nextContext);
    }
    if (typeof instance.UNSAFE_componentWillUpdate === 'function') {
      instance.UNSAFE_componentWillUpdate(newProps, newState, nextContext);
    }
  }
  if (typeof instance.componentDidUpdate === 'function') {
    workInProgress.flags |= Update;
  }
  if (typeof instance.getSnapshotBeforeUpdate === 'function') {
    workInProgress.flags |= Snapshot;
  }
} else {
  // If an update was already in progress, we should schedule an Update
  // effect even though we're bailing out, so that cWU/cDU are called.
  if (typeof instance.componentDidUpdate === 'function') {
    if (
      unresolvedOldProps !== current.memoizedProps ||
      oldState !== current.memoizedState
    ) {
      workInProgress.flags |= Update;
    }
  }
  if (typeof instance.getSnapshotBeforeUpdate === 'function') {
    if (
      unresolvedOldProps !== current.memoizedProps ||
      oldState !== current.memoizedState
    ) {
      workInProgress.flags |= Snapshot;
    }
  }
  
  // If shouldComponentUpdate returned false, we should still update the
  // memoized props/state to indicate that this work can be reused.
  workInProgress.memoizedProps = newProps;
  workInProgress.memoizedState = newState;
}

The final, well, almost final stage is the execution of the remaining lifecycle methods. In the event that the component needs to be re-rendered, the following methods will be executed:

  1. componentWillUpdate (only if the new API is not used)
  2. UNSAFE_componentWillUpdate (only if the new API is not used)
  3. componentDidUpdate
  4. getSnapshotBeforeUpdate

Otherwise, these methods will not be called, only the necessary utility flags will be set, and the properties and state will be stored in memoizedProps and memoizedState, respectively.

// Update the existing instance's state, props, and context pointers even
// if shouldComponentUpdate returns false.
instance.props = newProps;
instance.state = newState;
instance.context = nextContext;

return shouldUpdate;

And finally, the last stage. The new properties, state, and context values are assigned to the class instance, after which the function completes.

Shallow Comparison

The concept of shallow comparison has already been mentioned above. Let's delve into it in more detail. We talked about how this comparison occurs when implementing a PureComponent. Just to recap, let's remember what a PureComponent is. Let's use the example mentioned earlier and make slight modifications to it.

class MyComponent extends React.PureComponent<{ color?: string }> {
  static defaultProps = {
    color: 'blue',
  };
  
  render() {
    return (
      <div>
        {this.props.color}
      </div>
    );
  }
}

As you can see, the only difference is in which parent class we inherit our class component from (in the original version, we inherited from React.Component). It is in such a component that shallow comparison will be applied.

So, what exactly is shallow comparison? Perhaps the best answer to this question would be the code of the shallowEqual function itself, which we have seen before.

/packages/shared/shallowEqual.js#L13

/**
 * Performs equality by iterating through keys on an object and returning false
 * when any key has values which are not strictly equal between the arguments.
 * Returns true when the values of all keys are strictly equal.
 */
function shallowEqual(objA: mixed, objB: mixed): boolean {
  if (is(objA, objB)) {
    return true;
  }
  
  if (
    typeof objA !== 'object' ||
    objA === null ||
    typeof objB !== 'object' ||
    objB === null
  ) {
    return false;
  }
  
  const keysA = Object.keys(objA);
  const keysB = Object.keys(objB);
  
  if (keysA.length !== keysB.length) {
    return false;
  }
  
  // Test for A's keys different from B.
  for (let i = 0; i < keysA.length; i++) {
    const currentKey = keysA[i];
    if (
      !hasOwnProperty.call(objB, currentKey) ||
      !is(objA[currentKey], objB[currentKey])
    ) {
      return false;
    }
  }
  
  return true;
}

This utility function consists of two main parts:

  1. Comparing variables objA and objB.
  2. If the variables are equal or have equal references to the objects they represent, a comparison is made for each property of these objects.

The actual comparison is performed by another utility called is().

/packages/shared/objectIs.js#L10

/**
 * inlined Object.is polyfill to avoid requiring consumers ship their own
 * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is
 */
function is(x: any, y: any) {
  return (
    (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) // eslint-disable-line no-self-compare
  );
}

const objectIs: (x: any, y: any) => boolean =
  typeof Object.is === 'function' ? Object.is : is;

This utility is nothing but a function from the Web API - Object.is(). If the browser does not support this Web API method, a built-in polyfill is used.

In sort, two values are the same if one of the following holds:

  • both undefined
  • both null
  • both true or both false
  • both strings of the same length with the same characters in the same order
  • both the same object (meaning both values reference the same object in memory)
  • both BigInts with the same numeric value
  • both symbols that reference the same symbol value
  • both numbers and
    • both +0
    • both -0
    • both NaN
    • or both non-zero, not NaN, and have the same value

FunctionComponent

Let's switch to functional components now that we've covered class components.

const Component = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
const resolvedProps =
  workInProgress.elementType === Component
    ? unresolvedProps
    : resolveDefaultProps(Component, unresolvedProps);
return updateFunctionComponent(
  current,
  workInProgress,
  Component,
  resolvedProps,
  renderLanes,
);

At the very beginning of the begin phase of updating a functional component, there is no difference compared to updating a class component, except for the update function itself.

/packages/react-reconciler/src/ReactFiberBeginWork.new.js#L965

function updateFunctionComponent(
  current,
  workInProgress,
  Component,
  nextProps: any,
  renderLanes,
) {
  ...
  
  nextChildren = renderWithHooks(
    current,
    workInProgress,
    Component,
    nextProps,
    context,
    renderLanes,
  );
  hasId = checkDidRenderIdHook();

  ...
    
  if (current !== null && !didReceiveUpdate) {
    bailoutHooks(current, workInProgress, renderLanes);
    return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
  }
  
  if (getIsHydrating() && hasId) {
    pushMaterializedTreeId(workInProgress);
  }
  
  ...
  
  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  return workInProgress.child;
}

Specifically, what matters here is the launch of a separate flow called renderWithHooks, which is characteristic of functional components. Let's take a look at what's going on there.

/packages/react-reconciler/src/ReactFiberHooks.new.js#L374

export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
  renderLanes = nextRenderLanes;
  currentlyRenderingFiber = workInProgress;
  
  ...
  
  workInProgress.memoizedState = null;
  workInProgress.updateQueue = null;
  workInProgress.lanes = NoLanes;
  
  // The following should have already been reset
  // currentHook = null;
  // workInProgressHook = null;
  
  // didScheduleRenderPhaseUpdate = false;
  // localIdCounter = 0;
  
  // TODO Warn if no hooks are used at all during mount, then some are used during update.
  // Currently we will identify the update render as a mount because memoizedState === null.
  // This is tricky because it's valid for certain types of components (e.g. React.lazy)
  
  // Using memoizedState to differentiate between mount/update only works if at least one stateful hook is used.
  // Non-stateful hooks (e.g. context) don't get added to memoizedState,
  // so memoizedState would be null during updates and mounts.
  ReactCurrentDispatcher.current =
    current === null || current.memoizedState === null
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;
      
  let children = Component(props, secondArg);
  
  // Check if there was a render phase update
  if (didScheduleRenderPhaseUpdateDuringThisPass) {
    // Keep rendering in a loop for as long as render phase updates continue to
    // be scheduled. Use a counter to prevent infinite loops.
    let numberOfReRenders: number = 0;
    do {
      didScheduleRenderPhaseUpdateDuringThisPass = false;
      localIdCounter = 0;
      
      if (numberOfReRenders >= RE_RENDER_LIMIT) {
        throw new Error(
          'Too many re-renders. React limits the number of renders to prevent ' +
            'an infinite loop.',
        );
      }
      
      numberOfReRenders += 1;
      
      // Start over from the beginning of the list
      currentHook = null;
      workInProgressHook = null;
      
      workInProgress.updateQueue = null;
      
      ReactCurrentDispatcher.current = __DEV__
        ? HooksDispatcherOnRerenderInDEV
        : HooksDispatcherOnRerender;
        
      children = Component(props, secondArg);
    } while (didScheduleRenderPhaseUpdateDuringThisPass);
  }
  
  // We can assume the previous dispatcher is always this one, since we set it
  // at the beginning of the render phase and there's no re-entrance.
  ReactCurrentDispatcher.current = ContextOnlyDispatcher;
  
  // This check uses currentHook so that it works the same in DEV and prod bundles.
  // hookTypesDev could catch more cases (e.g. context) but only in DEV bundles.
  const didRenderTooFewHooks =
    currentHook !== null && currentHook.next !== null;
    
  renderLanes = NoLanes;
  currentlyRenderingFiber = (null: any);
  
  currentHook = null;
  workInProgressHook = null;
  
  didScheduleRenderPhaseUpdate = false;
  // This is reset by checkDidRenderIdHook
  // localIdCounter = 0;
  
  if (didRenderTooFewHooks) {
    throw new Error(
      'Rendered fewer hooks than expected. This may be caused by an accidental ' +
        'early return statement.',
    );
  }
  
  if (enableLazyContextPropagation) {
    if (current !== null) {
      if (!checkIfWorkInProgressReceivedUpdate()) {
        // If there were no changes to props or state, we need to check if there
        // was a context change. We didn't already do this because there's no
        // 1:1 correspondence between dependencies and hooks. Although, because
        // there almost always is in the common case (`readContext` is an
        // internal API), we could compare in there. OTOH, we only hit this case
        // if everything else bails out, so on the whole it might be better to
        // keep the comparison out of the common path.
        const currentDependencies = current.dependencies;
        if (
          currentDependencies !== null &&
          checkIfContextChanged(currentDependencies)
        ) {
          markWorkInProgressReceivedUpdate();
        }
      }
    }
  }
  return children;
}

The function may seem big and complex, but essentially, it does only two things. First, it defines the hook dispatcher, which will be responsible for the further execution of these hooks. Formally, in React, there are only 4 such dispatchers:

  • ContextOnlyDispatcher
  • HooksDispatcherOnMount
  • HooksDispatcherOnUpdate
  • HooksDispatcherOnRender

In reality, the ContextOnlyDispatcher does not implement anything; it serves as a kind of default dispatcher and remains in place until the engine identifies a more relevant dispatcher for the current component.

Among the remaining three dispatchers, HooksDispatcherOnUpdate and HooksDispatcherOnRender practically do not differ, both leading to the same implementations of update hooks (updateMemo, updateCallback, etc.). The separation of these two different dispatchers is purely logical, in case React developers ever need to create different versions of hooks for different phases. As for HooksDispatcherOnMount, it leads to separate implementations of mount hooks (mountMemo, mountCallback, etc.).

We will discuss hooks and their providers a little later. For now, let's just note that each hook has two versions: mount and update. At this stage, it is important to determine which version of the hooks the engine will execute. This is done as follows.

ReactCurrentDispatcher.current =
    current === null || current.memoizedState === null
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;

Where current is a reference to Fiber.alternate. If it is equal to null, it means the component has not been created yet. Similarly, if Fiber.alternate.memoizedState is empty, then the hooks have not been executed in this component yet. In both cases, the HooksDispatcherOnMount dispatcher will be applied. Otherwise, it will be HooksDispatcherOnUpdate.

The second thing the function does is create a component using let children = Component(props, secondArg) and repeats this process in a loop until all scheduled updates are completed in the current phase. And yes, this is where the Too many re-renders. React limits the number of renders to prevent an infinite loop. exception is thrown if the number of updates exceeds 25 iterations.

HooksDispatcherOnMount

The dispatcher code looks like this

/packages/react-reconciler/src/ReactFiberHooks.new.js#L2427

const HooksDispatcherOnMount: Dispatcher = {
  readContext,
  
  useCallback: mountCallback,
  useContext: readContext,
  useEffect: mountEffect,
  useImperativeHandle: mountImperativeHandle,
  useLayoutEffect: mountLayoutEffect,
  useInsertionEffect: mountInsertionEffect,
  useMemo: mountMemo,
  useReducer: mountReducer,
  useRef: mountRef,
  useState: mountState,
  useDebugValue: mountDebugValue,
  useDeferredValue: mountDeferredValue,
  useTransition: mountTransition,
  useMutableSource: mountMutableSource,
  useSyncExternalStore: mountSyncExternalStore,
  useId: mountId,
  
  unstable_isNewReconciler: enableNewReconciler,
};
if (enableCache) {
  (HooksDispatcherOnMount: Dispatcher).getCacheSignal = getCacheSignal;
  (HooksDispatcherOnMount: Dispatcher).getCacheForType = getCacheForType;
  (HooksDispatcherOnMount: Dispatcher).useCacheRefresh = mountRefresh;
}

We will not consider all mount functions of hooks, it is more important to understand the essence of these functions. Let's see this on the example of mountMemo.

/packages/react-reconciler/src/ReactFiberHooks.new.js#L1899

function mountMemo<T>(
  nextCreate: () => T,
  deps: Array<mixed> | void | null,
): T {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

As you can see, the hook code is embarrassingly simple. First, a reference to Fiber is defined. Then, the value is computed, which occurs in the custom callback nextCreate. Next, the computed value and the array of dependencies are saved in Fiber.memoizedState. Here it is, memoization!

HooksDispatcherOnUpdate

Now let's peek into the HooksDispatcherOnUpdate dispatcher.

/packages/react-reconciler/src/ReactFiberHooks.new.js#L2454

const HooksDispatcherOnUpdate: Dispatcher = {
  readContext,
  
  useCallback: updateCallback,
  useContext: readContext,
  useEffect: updateEffect,
  useImperativeHandle: updateImperativeHandle,
  useInsertionEffect: updateInsertionEffect,
  useLayoutEffect: updateLayoutEffect,
  useMemo: updateMemo,
  useReducer: updateReducer,
  useRef: updateRef,
  useState: updateState,
  useDebugValue: updateDebugValue,
  useDeferredValue: updateDeferredValue,
  useTransition: updateTransition,
  useMutableSource: updateMutableSource,
  useSyncExternalStore: updateSyncExternalStore,
  useId: updateId,
  
  unstable_isNewReconciler: enableNewReconciler,
};
if (enableCache) {
  (HooksDispatcherOnUpdate: Dispatcher).getCacheSignal = getCacheSignal;
  (HooksDispatcherOnUpdate: Dispatcher).getCacheForType = getCacheForType;
  (HooksDispatcherOnUpdate: Dispatcher).useCacheRefresh = updateRefresh;
}

As I mentioned earlier, this dispatcher differs from the previous one in that it leads to the update versions of the same hooks. Since in the previous example we looked at mountMemo, this time let's look at updateMemo.

/packages/react-reconciler/src/ReactFiberHooks.new.js#L1910

function updateMemo<T>(
  nextCreate: () => T,
  deps: Array<mixed> | void | null,
): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (prevState !== null) {
    // Assume these are defined. If they're not, areHookInputsEqual will warn.
    if (nextDeps !== null) {
      const prevDeps: Array<mixed> | null = prevState[1];
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        return prevState[0];
      }
    }
  }
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

Here we already have the memory effect that was initiated during the mounting phase. Now, before starting the computations, the hook will take the previous dependencies from Fiber.memoizedState[1] and compare them with the current ones using the function areHookInputsEqual(nextDeps, prevDeps).

/packages/react-reconciler/src/ReactFiberHooks.new.js#L327

function areHookInputsEqual(
  nextDeps: Array<mixed>,
  prevDeps: Array<mixed> | null,
) {
  ...
  
  if (prevDeps === null) {
    if (__DEV__) {
      console.error(
        '%s received a final argument during this render, but not during ' +
          'the previous render. Even though the final argument is optional, ' +
          'its type cannot change between renders.',
        currentHookNameInDev,
      );
    }
    return false;
  }
  
  ...
  
  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    if (is(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }
  return true;
}

Which, in turn, performs a shallow comparison of dependencies using the same Object.is function.

MemoComponent

It's time to talk about the third type of node that interests us - MemoComponent. Such a node can be created using React.memo.

const MyComponent = React.memo<{ color?: string }>(({ color }) => {
  return <div>{color}</div>;
});

This type of component will only rerender if the properties passed to it have changed. It looks like this:

const type = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
// Resolve outer props first, then resolve inner props.
let resolvedProps = resolveDefaultProps(type, unresolvedProps);

resolvedProps = resolveDefaultProps(type.type, resolvedProps);
return updateMemoComponent(
  current,
  workInProgress,
  type,
  resolvedProps,
  renderLanes,
);

The update function for these components is called updateMemoComponent. The function is quite long, I will only provide the part of it that we need within the scope of this article.

/packages/react-reconciler/src/ReactFiberBeginWork.new.js#L452

function updateMemoComponent(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  nextProps: any,
  renderLanes: Lanes,
): null | Fiber {
  ...
  
  const currentChild = ((current.child: any): Fiber); // This is always exactly one child
  const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
    current,
    renderLanes,
  );
  if (!hasScheduledUpdateOrContext) {
    // This will be the props with resolved defaultProps,
    // unlike current.memoizedProps which will be the unresolved ones.
    const prevProps = currentChild.memoizedProps;
    // Default to shallow comparison
    let compare = Component.compare;
    compare = compare !== null ? compare : shallowEqual;
    if (compare(prevProps, nextProps) && current.ref === workInProgress.ref) {
      return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
    }
  }
  // React DevTools reads this flag.
  workInProgress.flags |= PerformedWork;
  const newChild = createWorkInProgress(currentChild, nextProps);
  newChild.ref = workInProgress.ref;
  newChild.return = workInProgress;
  workInProgress.child = newChild;
  return newChild;
}

The essence of this code boils down to calling the comparison function compare(prevProps, nextProps), where compare can be either a custom function (the second argument of React.memo) or the default shallowEqual, which we have already seen earlier.

Conclusion

In this article, we took a peek under the hood of the React engine and examined the mechanisms of mounting and updating nodes. One of the most important mechanisms in React involved in these processes is memoization. We discussed it using examples of three types of Fibers: ClassComponent, FunctionComponent, and MemoComponent.

Now that we have a better understanding of these processes, it's time to draw some conclusions.

  • Whether it's a class, function, or a hook's dependencies, React uses the same approach as a comparison function - the Object.is() Web API method or its polyfill.
  • Each hook has two implementation versions, mount and update. The update version is usually more complex because it needs to compare dependencies. Developers cannot influence the specific version of the hook used by the engine. The mount version only runs during the first component mount.
  • Since React compares each dependency separately when processing a hook, it is not advisable to add unnecessary dependencies. Additionally, dependencies are stored in memory, so having a large number of them can impact resource consumption. In other words,
const value = useMemo(() => {
  return a + b
}, [a, b]);

this code will be slightly more performant than the following

const value = useMemo(() => {
  return a + b
}, [a, b, c]);

Similarly,

const SOME_CONST = "some value";

const MyComponent = ({ a, b }) => {
  // this hook is more performant
  const value1 = useMemo(() => {
    return a + SOME_CONST
  }, [a]);

  // than this one
  const value2 = useMemo(() => {
    return b + SOME_CONST
  }, [b, SOME_CONST]);  

  return null;
}
  • For the same reason, it is essential to adequately assess the need for memoization in general. For example,
const isEqual = useMemo(() => {
  return a === b
}, [a, b]);

It won't provide any performance boost. On the contrary, the engine will have to compare prevDeps.a === currDeps.a and prevDeps.b === currDeps.b each time. Moreover, an additional function will be introduced in the code, which will consume its own resources.

  • The previous point applies to React.memo as well. Developers should understand whether the effect of memoization will outweigh the overhead of maintaining it.


My telegram channels:

EN - https://t.me/frontend_almanac
RU - https://t.me/frontend_almanac_ru

Русская версия: https://blog.frontend-almanac.ru/react-memoization