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.
lazy()
kicks of the loading when rendered, it is NOT the most eager timing. You might need to load it sooner based on your applicationās needs. 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
- only loads the module when rendered
- 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 modulethis.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 modulethis.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()
.
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 implementationif (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 implementationif (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 classexport 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 classexport 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$ElementTypekey: 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 rootsmode |= 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-fallthroughdefault: {if (typeof type === 'object' && type !== null) {switch (type.$$typeof) {case REACT_PROVIDER_TYPE:fiberTag = ContextProvider;break getTag;case REACT_CONTEXT_TYPE:// This is a consumerfiberTag = 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$ElementTypekey: 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 rootsmode |= 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-fallthroughdefault: {if (typeof type === 'object' && type !== null) {switch (type.$$typeof) {case REACT_PROVIDER_TYPE:fiberTag = ContextProvider;break getTag;case REACT_CONTEXT_TYPE:// This is a consumerfiberTag = 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 toorenderLanes,);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()
andforwardRef()
are handled as special cases forlazy()
}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 toorenderLanes,);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()
andforwardRef()
are handled as special cases forlazy()
}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!