How lazy() works internally in React?

If you don’t need to render some heavy components right away, you possibly want to defer its loading to keep resource bundle small, lazy() is a built-in React API for this purpose, below is an example.

import { Suspense, lazy, useState } from 'react';

// delay forces loading delay, for demo purpose
const LazyComponent = lazy(() => delay(import('./Component.js')));

export default function App() {
  const [show, setShow] = useState(false)
  return <div>
    <label>
    <input type="checkbox" checked={show} onChange={() => setShow(show => !show)}/>
      show lazy component
    </label>
    <hr/>
    {show ? <Suspense fallback={<p>loading</p>}>
      <LazyComponent/>
    </Suspense> : '-'}
  </div>
}

function delay(promise) {
  return new Promise(resolve => {
    setTimeout(resolve, 2000);
  }).then(() => promise);
}

In this episode, let’s figure it out how it actually works internally.

1. Let’s try to implement lazy() by ourselves

So lazy() basically returns a new component that

  1. only loads the module when rendered
  2. suspends when loading is not done

Based on what we’ve learned from how Suspense works internally, the implementation could be quite straightforward, like below.

const createModuleLoader = (load) => {

A general-purpose module loader

return {
module: null,
promise: null,
error: null,
load() {
if (this.module != null) {
-------------------

If already loaded, then just use it

return this.module
}
if (this.error != null) {
throw this.error
}
if (this.promise == null) {
--------------------

If not loaded, and loading not even started,

just kick off the loading

this.promise = load().then((res) => {
// suppose we get an ES module
this.module = res.default
}, (error) => {
this.error = error
})
}
throw this.promise
------------------

If not loaded but loading has started,

then it is not ready, just throw the Promise to suspend

}
}
}
function lazy(load) {
const moduleLoader = createModuleLoader(load)
return function (props) {
const Component = moduleLoader.load()
return <Component {...props}/>
}
}
const createModuleLoader = (load) => {

A general-purpose module loader

return {
module: null,
promise: null,
error: null,
load() {
if (this.module != null) {
-------------------

If already loaded, then just use it

return this.module
}
if (this.error != null) {
throw this.error
}
if (this.promise == null) {
--------------------

If not loaded, and loading not even started,

just kick off the loading

this.promise = load().then((res) => {
// suppose we get an ES module
this.module = res.default
}, (error) => {
this.error = error
})
}
throw this.promise
------------------

If not loaded but loading has started,

then it is not ready, just throw the Promise to suspend

}
}
}
function lazy(load) {
const moduleLoader = createModuleLoader(load)
return function (props) {
const Component = moduleLoader.load()
return <Component {...props}/>
}
}

It actually works! Below is a rewrite of previous demo by using our version of lazy().

import { Suspense, useState } from 'react';
import lazy from './lazy';

// delay forces loading delay, for demo purpose
const LazyComponent = lazy(() => delay(import('./Component.js')));

export default function App() {
  const [show, setShow] = useState(false)
  return <div>
    <label>
    <input type="checkbox" checked={show} onChange={() => setShow(show => !show)}/>
      show lazy component
    </label>
    <hr/>
    {show ? <Suspense fallback={<p>loading</p>}>
      <LazyComponent/>
    </Suspense> : '-'}
  </div>
}

function delay(promise) {
  return new Promise(resolve => {
    setTimeout(resolve, 2000);
  }).then(() => promise);
}

Guess potentially the internal implementation could be something similar ? Let’s dive in.

2. The internals of React API - lazy()

2.1 lazy() returns a special React Element of REACT_LAZY_TYPE

export function lazy<T>(
ctor: () => Thenable<{default: T, ...}>,
): LazyComponent<T, Payload<T>> {
const payload: Payload<T> = {
// We use these fields to store the result.
_status: Uninitialized,
_result: ctor,
};
const lazyType: LazyComponent<T, Payload<T>> = {
$$typeof: REACT_LAZY_TYPE,
_payload: payload,

payload holds the component and status

_init: lazyInitializer,
};
return lazyType;
--------

Different from implementation, it returns a special React element,

not a function component

}
export function lazy<T>(
ctor: () => Thenable<{default: T, ...}>,
): LazyComponent<T, Payload<T>> {
const payload: Payload<T> = {
// We use these fields to store the result.
_status: Uninitialized,
_result: ctor,
};
const lazyType: LazyComponent<T, Payload<T>> = {
$$typeof: REACT_LAZY_TYPE,
_payload: payload,

payload holds the component and status

_init: lazyInitializer,
};
return lazyType;
--------

Different from implementation, it returns a special React element,

not a function component

}
function lazyInitializer<T>(payload: Payload<T>): T {

This is kinda like the load() in our implementation

if (payload._status === Uninitialized) {
const ctor = payload._result;
const thenable = ctor();
// Transition to the next state.
// This might throw either because it's missing or throws. If so, we treat it
// as still uninitialized and try again next time. Which is the same as what
// happens if the ctor or any wrappers processing the ctor throws. This might
// end up fixing it if the resolution was a concurrency bug.
thenable.then(
moduleObject => {
if (payload._status === Pending || payload._status === Uninitialized) {
// Transition to the next state.
const resolved: ResolvedPayload<T> = (payload: any);
resolved._status = Resolved;
resolved._result = moduleObject;
}
},
error => {
if (payload._status === Pending || payload._status === Uninitialized) {
// Transition to the next state.
const rejected: RejectedPayload = (payload: any);
rejected._status = Rejected;
rejected._result = error;
}
},
);
if (payload._status === Uninitialized) {
// In case, we're still uninitialized, then we're waiting for the thenable
// to resolve. Set it as pending in the meantime.
const pending: PendingPayload = (payload: any);
pending._status = Pending;
pending._result = thenable;
}
}
if (payload._status === Resolved) {
const moduleObject = payload._result;
return moduleObject.default;
---------------------------
} else {
throw payload._result;
----------------------

this throwing of Promise suspends

}
}
function lazyInitializer<T>(payload: Payload<T>): T {

This is kinda like the load() in our implementation

if (payload._status === Uninitialized) {
const ctor = payload._result;
const thenable = ctor();
// Transition to the next state.
// This might throw either because it's missing or throws. If so, we treat it
// as still uninitialized and try again next time. Which is the same as what
// happens if the ctor or any wrappers processing the ctor throws. This might
// end up fixing it if the resolution was a concurrency bug.
thenable.then(
moduleObject => {
if (payload._status === Pending || payload._status === Uninitialized) {
// Transition to the next state.
const resolved: ResolvedPayload<T> = (payload: any);
resolved._status = Resolved;
resolved._result = moduleObject;
}
},
error => {
if (payload._status === Pending || payload._status === Uninitialized) {
// Transition to the next state.
const rejected: RejectedPayload = (payload: any);
rejected._status = Rejected;
rejected._result = error;
}
},
);
if (payload._status === Uninitialized) {
// In case, we're still uninitialized, then we're waiting for the thenable
// to resolve. Set it as pending in the meantime.
const pending: PendingPayload = (payload: any);
pending._status = Pending;
pending._result = thenable;
}
}
if (payload._status === Resolved) {
const moduleObject = payload._result;
return moduleObject.default;
---------------------------
} else {
throw payload._result;
----------------------

this throwing of Promise suspends

}
}

We can see that lazy() actually returns a special React Element type - REACT_LAZY_TYPE, but in our implementation we just return a function component. The loading logic is quite similar - just cache the loaded data and throws Promise if ongoing.

Actually there are quite a lot of React Element Types.

// The Symbol used to tag the ReactElement-like types.
export const REACT_ELEMENT_TYPE = Symbol.for('react.element');
------------------------------------------------

This covers custom Function/Class components and intrinsic HTML tags

The other types are special ones that have special logic

export const REACT_PORTAL_TYPE = Symbol.for('react.portal');
export const REACT_FRAGMENT_TYPE = Symbol.for('react.fragment');
export const REACT_STRICT_MODE_TYPE = Symbol.for('react.strict_mode');
export const REACT_PROFILER_TYPE = Symbol.for('react.profiler');
export const REACT_PROVIDER_TYPE = Symbol.for('react.provider');
export const REACT_CONTEXT_TYPE = Symbol.for('react.context');
export const REACT_SERVER_CONTEXT_TYPE = Symbol.for('react.server_context');
export const REACT_FORWARD_REF_TYPE = Symbol.for('react.forward_ref');
export const REACT_SUSPENSE_TYPE = Symbol.for('react.suspense');
export const REACT_SUSPENSE_LIST_TYPE = Symbol.for('react.suspense_list');
export const REACT_MEMO_TYPE = Symbol.for('react.memo');
export const REACT_LAZY_TYPE = Symbol.for('react.lazy');
-------------------------------------------------
export const REACT_SCOPE_TYPE = Symbol.for('react.scope');
export const REACT_DEBUG_TRACING_MODE_TYPE = Symbol.for(
'react.debug_trace_mode',
);
export const REACT_OFFSCREEN_TYPE = Symbol.for('react.offscreen');
export const REACT_LEGACY_HIDDEN_TYPE = Symbol.for('react.legacy_hidden');
export const REACT_CACHE_TYPE = Symbol.for('react.cache');
export const REACT_TRACING_MARKER_TYPE = Symbol.for('react.tracing_marker');
export const REACT_SERVER_CONTEXT_DEFAULT_VALUE_NOT_LOADED = Symbol.for(
'react.default_value',
);
// The Symbol used to tag the ReactElement-like types.
export const REACT_ELEMENT_TYPE = Symbol.for('react.element');
------------------------------------------------

This covers custom Function/Class components and intrinsic HTML tags

The other types are special ones that have special logic

export const REACT_PORTAL_TYPE = Symbol.for('react.portal');
export const REACT_FRAGMENT_TYPE = Symbol.for('react.fragment');
export const REACT_STRICT_MODE_TYPE = Symbol.for('react.strict_mode');
export const REACT_PROFILER_TYPE = Symbol.for('react.profiler');
export const REACT_PROVIDER_TYPE = Symbol.for('react.provider');
export const REACT_CONTEXT_TYPE = Symbol.for('react.context');
export const REACT_SERVER_CONTEXT_TYPE = Symbol.for('react.server_context');
export const REACT_FORWARD_REF_TYPE = Symbol.for('react.forward_ref');
export const REACT_SUSPENSE_TYPE = Symbol.for('react.suspense');
export const REACT_SUSPENSE_LIST_TYPE = Symbol.for('react.suspense_list');
export const REACT_MEMO_TYPE = Symbol.for('react.memo');
export const REACT_LAZY_TYPE = Symbol.for('react.lazy');
-------------------------------------------------
export const REACT_SCOPE_TYPE = Symbol.for('react.scope');
export const REACT_DEBUG_TRACING_MODE_TYPE = Symbol.for(
'react.debug_trace_mode',
);
export const REACT_OFFSCREEN_TYPE = Symbol.for('react.offscreen');
export const REACT_LEGACY_HIDDEN_TYPE = Symbol.for('react.legacy_hidden');
export const REACT_CACHE_TYPE = Symbol.for('react.cache');
export const REACT_TRACING_MARKER_TYPE = Symbol.for('react.tracing_marker');
export const REACT_SERVER_CONTEXT_DEFAULT_VALUE_NOT_LOADED = Symbol.for(
'react.default_value',
);

Notice that above types are types of React Elements, basically means the types of JSX elements. React Elements are blueprints for React runtime to construct and update Fiber Tree.

And in the realm of Fiber, there are also all kinds of Fiber types(tag). They are not mapped in 1:1 though, some are Fiber-only.

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;

HostComponent means the HTML tags in Host DOM

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;
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;

HostComponent means the HTML tags in Host DOM

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;

As I said React Elements are blueprints for Fiber tree, we can find the logic that do the mapping from REACT_LAZY_TYPE to LazyComponent.

export function createFiberFromElement(
----------------------
element: ReactElement,
mode: TypeOfMode,
lanes: Lanes,
): Fiber {
let owner = null;
const type = element.type;
const key = element.key;
const pendingProps = element.props;
const fiber = createFiberFromTypeAndProps(
---------------------------
type,
key,
pendingProps,
owner,
mode,
lanes,
);
return fiber;
}
export function createFiberFromTypeAndProps(
type: any, // React$ElementType
key: null | string,
pendingProps: any,
owner: null | Fiber,
mode: TypeOfMode,
lanes: Lanes,
): Fiber {
let fiberTag = IndeterminateComponent;
// The resolved type is set if we know what the final type will be. I.e. it's not lazy.
let resolvedType = type;
if (typeof type === 'function') {
if (shouldConstruct(type)) {
fiberTag = ClassComponent;
}

Custom components

} else if (typeof type === 'string') {
fiberTag = HostComponent;

HTML tags

} else {
getTag: switch (type) {
case REACT_FRAGMENT_TYPE:
return createFiberFromFragment(pendingProps.children, mode, lanes, key);
case REACT_STRICT_MODE_TYPE:
fiberTag = Mode;
mode |= StrictLegacyMode;
if (enableStrictEffects && (mode & ConcurrentMode) !== NoMode) {
// Strict effects should never run on legacy roots
mode |= StrictEffectsMode;
}
break;
case REACT_PROFILER_TYPE:
return createFiberFromProfiler(pendingProps, mode, lanes, key);
case REACT_SUSPENSE_TYPE:
return createFiberFromSuspense(pendingProps, mode, lanes, key);
....
// eslint-disable-next-line no-fallthrough
default: {
if (typeof type === 'object' && type !== null) {
switch (type.$$typeof) {
case REACT_PROVIDER_TYPE:
fiberTag = ContextProvider;
break getTag;
case REACT_CONTEXT_TYPE:
// This is a consumer
fiberTag = ContextConsumer;
break getTag;
case REACT_FORWARD_REF_TYPE:
fiberTag = ForwardRef;
break getTag;
case REACT_MEMO_TYPE:
fiberTag = MemoComponent;
break getTag;
case REACT_LAZY_TYPE:
fiberTag = LazyComponent;
resolvedType = null;
break getTag;

Handling REACT_LAZY_TYPE

}
}
}
}
}
const fiber = createFiber(fiberTag, pendingProps, key, mode);
fiber.elementType = type;
fiber.type = resolvedType;
fiber.lanes = lanes;
return fiber;
}
export function createFiberFromElement(
----------------------
element: ReactElement,
mode: TypeOfMode,
lanes: Lanes,
): Fiber {
let owner = null;
const type = element.type;
const key = element.key;
const pendingProps = element.props;
const fiber = createFiberFromTypeAndProps(
---------------------------
type,
key,
pendingProps,
owner,
mode,
lanes,
);
return fiber;
}
export function createFiberFromTypeAndProps(
type: any, // React$ElementType
key: null | string,
pendingProps: any,
owner: null | Fiber,
mode: TypeOfMode,
lanes: Lanes,
): Fiber {
let fiberTag = IndeterminateComponent;
// The resolved type is set if we know what the final type will be. I.e. it's not lazy.
let resolvedType = type;
if (typeof type === 'function') {
if (shouldConstruct(type)) {
fiberTag = ClassComponent;
}

Custom components

} else if (typeof type === 'string') {
fiberTag = HostComponent;

HTML tags

} else {
getTag: switch (type) {
case REACT_FRAGMENT_TYPE:
return createFiberFromFragment(pendingProps.children, mode, lanes, key);
case REACT_STRICT_MODE_TYPE:
fiberTag = Mode;
mode |= StrictLegacyMode;
if (enableStrictEffects && (mode & ConcurrentMode) !== NoMode) {
// Strict effects should never run on legacy roots
mode |= StrictEffectsMode;
}
break;
case REACT_PROFILER_TYPE:
return createFiberFromProfiler(pendingProps, mode, lanes, key);
case REACT_SUSPENSE_TYPE:
return createFiberFromSuspense(pendingProps, mode, lanes, key);
....
// eslint-disable-next-line no-fallthrough
default: {
if (typeof type === 'object' && type !== null) {
switch (type.$$typeof) {
case REACT_PROVIDER_TYPE:
fiberTag = ContextProvider;
break getTag;
case REACT_CONTEXT_TYPE:
// This is a consumer
fiberTag = ContextConsumer;
break getTag;
case REACT_FORWARD_REF_TYPE:
fiberTag = ForwardRef;
break getTag;
case REACT_MEMO_TYPE:
fiberTag = MemoComponent;
break getTag;
case REACT_LAZY_TYPE:
fiberTag = LazyComponent;
resolvedType = null;
break getTag;

Handling REACT_LAZY_TYPE

}
}
}
}
}
const fiber = createFiber(fiberTag, pendingProps, key, mode);
fiber.elementType = type;
fiber.type = resolvedType;
fiber.lanes = lanes;
return fiber;
}

By now we see how special Fiber is created for REACT_LAZY_TYPE.

2.2 mountLazyComponent() processes the special Fiber Node for lazy().

As we explained how React does the initial mount and re-render, we can easily find the code that processes the Fiber node for lazy() in beginWork().

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,
);
}
....
}
}
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,
);
}
....
}
}
function mountLazyComponent(
_current,
workInProgress,
elementType,
renderLanes,
) {
const props = workInProgress.pendingProps;
const lazyComponent: LazyComponentType<any, any> = elementType;
-----------

Recall in 2.2 that

The React Element for lazy() holds all the info needed to load the component

const payload = lazyComponent._payload;
const init = lazyComponent._init;
let Component = init(payload);
-------------------------

The init() is called directly, just like what we did with moduleLoader.load()

// Store the unwrapped component in the type.
workInProgress.type = Component;
const resolvedTag = (workInProgress.tag = resolveLazyComponentTag(Component));
-----------------------
const resolvedProps = resolveDefaultProps(Component, props);
let child;

From code below, we see that child is actually the grand child of lazy

explanation is right after this

switch (resolvedTag) {
case FunctionComponent: {
child = updateFunctionComponent(
null,
workInProgress,
Component,
resolvedProps,
renderLanes,
);
return child;
}
case ClassComponent: {
child = updateClassComponent(
null,
workInProgress,
Component,
resolvedProps,
renderLanes,
);
return child;
}
case ForwardRef: {
child = updateForwardRef(
null,
workInProgress,
Component,
resolvedProps,
renderLanes,
);
return child;
}
case MemoComponent: {
child = updateMemoComponent(
null,
workInProgress,
Component,
resolveDefaultProps(Component.type, resolvedProps), // The inner type can have defaults too
renderLanes,
);
return child;
}
}

Notice that Fiber tags other than above are not processed and errors are thrown

// This message intentionally doesn't mention ForwardRef or MemoComponent
// because the fact that it's a separate type of work is an
// implementation detail.
throw new Error(
`Element type is invalid. Received a promise that resolves to: ${Component}. ` +
`Lazy element type must resolve to a class or function.${hint}`,
);
}
export function resolveLazyComponentTag(Component: Function): WorkTag {
if (typeof Component === 'function') {
return shouldConstruct(Component) ? ClassComponent : FunctionComponent;
} else if (Component !== undefined && Component !== null) {
const $$typeof = Component.$$typeof;
if ($$typeof === REACT_FORWARD_REF_TYPE) {
return ForwardRef;
}
if ($$typeof === REACT_MEMO_TYPE) {
return MemoComponent;
}

memo() and forwardRef() are handled as special cases for lazy()

}
return IndeterminateComponent;
}
function mountLazyComponent(
_current,
workInProgress,
elementType,
renderLanes,
) {
const props = workInProgress.pendingProps;
const lazyComponent: LazyComponentType<any, any> = elementType;
-----------

Recall in 2.2 that

The React Element for lazy() holds all the info needed to load the component

const payload = lazyComponent._payload;
const init = lazyComponent._init;
let Component = init(payload);
-------------------------

The init() is called directly, just like what we did with moduleLoader.load()

// Store the unwrapped component in the type.
workInProgress.type = Component;
const resolvedTag = (workInProgress.tag = resolveLazyComponentTag(Component));
-----------------------
const resolvedProps = resolveDefaultProps(Component, props);
let child;

From code below, we see that child is actually the grand child of lazy

explanation is right after this

switch (resolvedTag) {
case FunctionComponent: {
child = updateFunctionComponent(
null,
workInProgress,
Component,
resolvedProps,
renderLanes,
);
return child;
}
case ClassComponent: {
child = updateClassComponent(
null,
workInProgress,
Component,
resolvedProps,
renderLanes,
);
return child;
}
case ForwardRef: {
child = updateForwardRef(
null,
workInProgress,
Component,
resolvedProps,
renderLanes,
);
return child;
}
case MemoComponent: {
child = updateMemoComponent(
null,
workInProgress,
Component,
resolveDefaultProps(Component.type, resolvedProps), // The inner type can have defaults too
renderLanes,
);
return child;
}
}

Notice that Fiber tags other than above are not processed and errors are thrown

// This message intentionally doesn't mention ForwardRef or MemoComponent
// because the fact that it's a separate type of work is an
// implementation detail.
throw new Error(
`Element type is invalid. Received a promise that resolves to: ${Component}. ` +
`Lazy element type must resolve to a class or function.${hint}`,
);
}
export function resolveLazyComponentTag(Component: Function): WorkTag {
if (typeof Component === 'function') {
return shouldConstruct(Component) ? ClassComponent : FunctionComponent;
} else if (Component !== undefined && Component !== null) {
const $$typeof = Component.$$typeof;
if ($$typeof === REACT_FORWARD_REF_TYPE) {
return ForwardRef;
}
if ($$typeof === REACT_MEMO_TYPE) {
return MemoComponent;
}

memo() and forwardRef() are handled as special cases for lazy()

}
return IndeterminateComponent;
}

So actually the implementation is quite similar to what we did, except they do one more step inside of the special Fiber node and process the component under it directly without extra one round of Fiber tree traversal.

2.3 memo(), forwardRef() and custom components are eagerly processed.

So you might ask if our implementation works, why bother all these special logic in built-in lazy()?

I think there are 2 reasons.

1. to prevent unexpected types under lazy()

See from the error message "Lazy element type must resolve to a class or function", We are not able to implement this by ourselves without React exposing the internal types for us, so lazy() is worthy being a built-in API that hides the internal implementation.

2. eagerly resolves to reduce unnecessary Fiber Node

In our implementation, lazy() returns a wrapper Function Component for the loaded Component, this results in a Fiber node for the wrapper and a Fiber node for the loaded Component.

But in built-in lazy(), as I pointed out specially for child, there is no FiberNode for the loaded Component, since in the wrapper the rendering of the loading component is eagerly done. This could possibly keep the Fiber Tree more compact and improve rendering performance.

That’s all for lazy(), hope my explanation helps you understand it better!

Want to know more about how React works internally?
Check out my series - React Internals Deep Dive!

😳 Would you like to share my post to more people ?    

❮ Prev: How does React re-render internally?

Next: How forwardRef() works internally in React?