How does use() work internally in React?
use()
is still experimental and will be ready for upcoming React@19, this post is based on commit@1293047. As described on React.dev,
use()
is a React Hook that lets you read the value of a resource like a Promise or context.
js
import { use } from 'react';function MessageComponent({ messagePromise }) {const message = use(messagePromise);const theme = use(ThemeContext);// ...}
js
import { use } from 'react';function MessageComponent({ messagePromise }) {const message = use(messagePromise);const theme = use(ThemeContext);// ...}
Looking at the source code, use()
internally uses useThenable()
for Promise and readContext()
for context.
function use<T>(usable: Usable<T>): T {if (usable !== null && typeof usable === 'object') {if (typeof usable.then === 'function') {const thenable: Thenable<T> = (usable: any);return useThenable(thenable);} else if (usable.$$typeof === REACT_CONTEXT_TYPE) {const context: ReactContext<T> = (usable: any);return readContext(context);}}throw new Error('An unsupported type was passed to use(): ' + String(usable));}
function use<T>(usable: Usable<T>): T {if (usable !== null && typeof usable === 'object') {if (typeof usable.then === 'function') {const thenable: Thenable<T> = (usable: any);return useThenable(thenable);} else if (usable.$$typeof === REACT_CONTEXT_TYPE) {const context: ReactContext<T> = (usable: any);return readContext(context);}}throw new Error('An unsupported type was passed to use(): ' + String(usable));}
2. Let’s create our own useThenable()
By resource I mean an object that holds its own loading state and it is fairly easy to enable Suspense around it.
In the episode of how lazy() works internally in React, we tried to implement a module loader to do dynamic import as resource,
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}/>}}
With above code, the lazily loaded component becomes a resource that properly suspends when loading is not done, try it out 👇
The module loader accepts argument of loader that return Promises, we can tweak it a little bit to accept Promise directly.
Maybe with a global store to hold the states of the Promises.
const statusStore = new WeakMap()to NOT prevent the promises from being garbage collected
export function myUseThenable(thenable) {if (!statusStore.has(thenable)) {statusStore.set(thenable, {status: 'pending',})}const entry = statusStore.get(thenable)if (entry.status === 'pending') {throw thenable}Suspends while loading
if (entry.status === 'fulfilled') {return entry.value}if (entry.status === 'rejected') {throw entry.error}}
const statusStore = new WeakMap()to NOT prevent the promises from being garbage collected
export function myUseThenable(thenable) {if (!statusStore.has(thenable)) {statusStore.set(thenable, {status: 'pending',})}const entry = statusStore.get(thenable)if (entry.status === 'pending') {throw thenable}Suspends while loading
if (entry.status === 'fulfilled') {return entry.value}if (entry.status === 'rejected') {throw entry.error}}
Here is a rewrite of the demo with our myUseThenable()
.
The usage looks exactly the same as use()
. 🎉
js
function LazyComponent() {const Component = myUseThenable(ComponentThenable)return <Component/>}
js
function LazyComponent() {const Component = myUseThenable(ComponentThenable)return <Component/>}
But the global Promise store looks heavy. Why don’t we just store the status directly on the promise?
export function myUseThenable(thenable) {if (typeof thenable === 'object'&& thenable != null'then' in thenable} {if (thenable.status === 'pending') {throw thenable}Suspend while loading
if (thenable.status === 'fulfilled') {return thenable.value}if (thenable.status === 'rejected') {throw thenable.error}thenable.status = 'pending'thenable.then((data) => {thenable.status = 'fulfilled'thenable.data = data}, (error) => {thenable.status = 'rejected'thenable.error = error})} else {throw new Error('not thenable')}}
export function myUseThenable(thenable) {if (typeof thenable === 'object'&& thenable != null'then' in thenable} {if (thenable.status === 'pending') {throw thenable}Suspend while loading
if (thenable.status === 'fulfilled') {return thenable.value}if (thenable.status === 'rejected') {throw thenable.error}thenable.status = 'pending'thenable.then((data) => {thenable.status = 'fulfilled'thenable.data = data}, (error) => {thenable.status = 'rejected'thenable.error = error})} else {throw new Error('not thenable')}}
This looks more straightforward now.
3. How does useThenable()
work internally?
Let’s dive into the source code.
let thenableIndexCounter: number = 0;cursor for
use
-d Promises inside one fiberlet thenableState: ThenableState | null = null;list of
use
-d Promises inside one fiberfunction useThenable<T>(thenable: Thenable<T>): T {// Track the position of the thenable within this fiber.const index = thenableIndexCounter;thenableIndexCounter += 1;if (thenableState === null) {thenableState = createThenableState();}const result = trackUsedThenable(thenableState, thenable, index);For each Fiber, the thenables are stored in an array
if (currentlyRenderingFiber.alternate === null &&Initial mount
(workInProgressHook === nullIf there is no other hooks called before useThenable()
? currentlyRenderingFiber.memoizedState === nulland there is no hooks waiting to be run
: workInProgressHook.next === null)Though there are hooks called before useThenable(),
but that is the end from previous render,
Meaning thenable threw or current useThenable() is the last hook
) {// Initial render, and either this is the first time the component is// called, or there were no Hooks called after this use() the previous// time (perhaps because it threw). Subsequent Hook calls should use the// mount dispatcher.if (__DEV__) {ReactCurrentDispatcher.current = HooksDispatcherOnMountInDEV;} else {ReactCurrentDispatcher.current = HooksDispatcherOnMount;}}This is something we cannot implement by ourselves!
Like what we've explained in How does useState() works internally,
useState()
internally usemountState()
for initial mountand
updateState()
for updates.But if
useThenable()
here throws(suspends), then the hooks after itmight not get called for initial mount and later treated as update in the double-rendering
The if conditions are to check that no hooks were called after it
and then forces the hooks to use
mount
version.fore more info, refer to the detailed issue report
return result;}
let thenableIndexCounter: number = 0;cursor for
use
-d Promises inside one fiberlet thenableState: ThenableState | null = null;list of
use
-d Promises inside one fiberfunction useThenable<T>(thenable: Thenable<T>): T {// Track the position of the thenable within this fiber.const index = thenableIndexCounter;thenableIndexCounter += 1;if (thenableState === null) {thenableState = createThenableState();}const result = trackUsedThenable(thenableState, thenable, index);For each Fiber, the thenables are stored in an array
if (currentlyRenderingFiber.alternate === null &&Initial mount
(workInProgressHook === nullIf there is no other hooks called before useThenable()
? currentlyRenderingFiber.memoizedState === nulland there is no hooks waiting to be run
: workInProgressHook.next === null)Though there are hooks called before useThenable(),
but that is the end from previous render,
Meaning thenable threw or current useThenable() is the last hook
) {// Initial render, and either this is the first time the component is// called, or there were no Hooks called after this use() the previous// time (perhaps because it threw). Subsequent Hook calls should use the// mount dispatcher.if (__DEV__) {ReactCurrentDispatcher.current = HooksDispatcherOnMountInDEV;} else {ReactCurrentDispatcher.current = HooksDispatcherOnMount;}}This is something we cannot implement by ourselves!
Like what we've explained in How does useState() works internally,
useState()
internally usemountState()
for initial mountand
updateState()
for updates.But if
useThenable()
here throws(suspends), then the hooks after itmight not get called for initial mount and later treated as update in the double-rendering
The if conditions are to check that no hooks were called after it
and then forces the hooks to use
mount
version.fore more info, refer to the detailed issue report
return result;}
thenableIndexCounter
and thenableState
are actually reset
in finishRenderingHooks()
for each fiber, meaning thenableState
is always newly created for each render!
function finishRenderingHooks<Props, SecondArg>(current: Fiber | null,workInProgress: Fiber,Component: (p: Props, arg: SecondArg) => any,): void {thenableIndexCounter = 0;thenableState = null;}
function finishRenderingHooks<Props, SecondArg>(current: Fiber | null,workInProgress: Fiber,Component: (p: Props, arg: SecondArg) => any,): void {thenableIndexCounter = 0;thenableState = null;}
And thenableState
is just an array.
export function createThenableState(): ThenableState {// The ThenableState is created the first time a component suspends. If it// suspends again, we'll reuse the same state.if (__DEV__) {return {didWarnAboutUncachedPromise: false,thenables: [],};} else {return [];}}
export function createThenableState(): ThenableState {// The ThenableState is created the first time a component suspends. If it// suspends again, we'll reuse the same state.if (__DEV__) {return {didWarnAboutUncachedPromise: false,thenables: [],};} else {return [];}}
The core logic is in trackUsedThenable()
.
export function trackUsedThenable<T>(thenableState: ThenableState,thenable: Thenable<T>,index: number,): T {const trackedThenables = getThenablesFromState(thenableState);const previous = trackedThenables[index];if (previous === undefined) {Since thenableState is newly created on each render,
This means
previous
will always be undefined?Kinda yes, but don't forget that we have double-rendering under Strict Mode!
So it is not always true
trackedThenables.push(thenable);} else {if (previous !== thenable) {// Reuse the previous thenable, and drop the new one. We can assume// they represent the same value, because components are idempotent.if (__DEV__) {const thenableStateDev: ThenableStateDev = (thenableState: any);if (!thenableStateDev.didWarnAboutUncachedPromise) {thenableStateDev.didWarnAboutUncachedPromise = true;// TODO: This warning should link to a corresponding docs page.console.error('A component was suspended by an uncached promise. Creating ' +'promises inside a Client Component or hook is not yet ' +'supported, except via a Suspense-compatible library or framework.',);}}Actually this is warning is the whole purpose of tracking the promise in an array
It is to make sure we externalize the promise - NO promise creation in render!
// Avoid an unhandled rejection errors for the Promises that we'll// intentionally ignore.thenable.then(noop, noop);thenable = previous;}}switch (thenable.status) {case 'fulfilled': {const fulfilledValue: T = thenable.value;return fulfilledValue;}case 'rejected': {const rejectedError = thenable.reason;checkIfUseWrappedInAsyncCatch(rejectedError);throw rejectedError;}default: {if (typeof thenable.status === 'string') {thenable.then(noop, noop);} else {const pendingThenable: PendingThenable<T> = (thenable: any);pendingThenable.status = 'pending';pendingThenable.then(fulfilledValue => {if (thenable.status === 'pending') {const fulfilledThenable: FulfilledThenable<T> = (thenable: any);fulfilledThenable.status = 'fulfilled';fulfilledThenable.value = fulfilledValue;}},success callback
(error: mixed) => {if (thenable.status === 'pending') {const rejectedThenable: RejectedThenable<T> = (thenable: any);rejectedThenable.status = 'rejected';rejectedThenable.reason = error;}},error callback
);}// Check one more time in case the thenable resolved synchronously.---------------------------------------------------------------Is this possible? Basically not
but it might be possible if
then()
is somehow hacked with other implementationsLike this demo I tried.
switch (thenable.status) {case 'fulfilled': {const fulfilledThenable: FulfilledThenable<T> = (thenable: any);return fulfilledThenable.value;}case 'rejected': {const rejectedThenable: RejectedThenable<T> = (thenable: any);const rejectedError = rejectedThenable.reason;checkIfUseWrappedInAsyncCatch(rejectedError);throw rejectedError;}}suspendedThenable = thenable;throw SuspenseException;Suspends!
}}}
export function trackUsedThenable<T>(thenableState: ThenableState,thenable: Thenable<T>,index: number,): T {const trackedThenables = getThenablesFromState(thenableState);const previous = trackedThenables[index];if (previous === undefined) {Since thenableState is newly created on each render,
This means
previous
will always be undefined?Kinda yes, but don't forget that we have double-rendering under Strict Mode!
So it is not always true
trackedThenables.push(thenable);} else {if (previous !== thenable) {// Reuse the previous thenable, and drop the new one. We can assume// they represent the same value, because components are idempotent.if (__DEV__) {const thenableStateDev: ThenableStateDev = (thenableState: any);if (!thenableStateDev.didWarnAboutUncachedPromise) {thenableStateDev.didWarnAboutUncachedPromise = true;// TODO: This warning should link to a corresponding docs page.console.error('A component was suspended by an uncached promise. Creating ' +'promises inside a Client Component or hook is not yet ' +'supported, except via a Suspense-compatible library or framework.',);}}Actually this is warning is the whole purpose of tracking the promise in an array
It is to make sure we externalize the promise - NO promise creation in render!
// Avoid an unhandled rejection errors for the Promises that we'll// intentionally ignore.thenable.then(noop, noop);thenable = previous;}}switch (thenable.status) {case 'fulfilled': {const fulfilledValue: T = thenable.value;return fulfilledValue;}case 'rejected': {const rejectedError = thenable.reason;checkIfUseWrappedInAsyncCatch(rejectedError);throw rejectedError;}default: {if (typeof thenable.status === 'string') {thenable.then(noop, noop);} else {const pendingThenable: PendingThenable<T> = (thenable: any);pendingThenable.status = 'pending';pendingThenable.then(fulfilledValue => {if (thenable.status === 'pending') {const fulfilledThenable: FulfilledThenable<T> = (thenable: any);fulfilledThenable.status = 'fulfilled';fulfilledThenable.value = fulfilledValue;}},success callback
(error: mixed) => {if (thenable.status === 'pending') {const rejectedThenable: RejectedThenable<T> = (thenable: any);rejectedThenable.status = 'rejected';rejectedThenable.reason = error;}},error callback
);}// Check one more time in case the thenable resolved synchronously.---------------------------------------------------------------Is this possible? Basically not
but it might be possible if
then()
is somehow hacked with other implementationsLike this demo I tried.
switch (thenable.status) {case 'fulfilled': {const fulfilledThenable: FulfilledThenable<T> = (thenable: any);return fulfilledThenable.value;}case 'rejected': {const rejectedThenable: RejectedThenable<T> = (thenable: any);const rejectedError = rejectedThenable.reason;checkIfUseWrappedInAsyncCatch(rejectedError);throw rejectedError;}}suspendedThenable = thenable;throw SuspenseException;Suspends!
}}}
We can see that the overall implementation is quite similar.
The complexity of actual use()
is that it tries to catch untracked
promises to prevent bad code like below.
js
function Component() {const promise = loadSomething()// ❌ this is not good// since this results in different promises on each render}const promise = loadSomething()// ✅ this is good// since promise is stable across all rendersfunction Component() {// ...}
js
function Component() {const promise = loadSomething()// ❌ this is not good// since this results in different promises on each render}const promise = loadSomething()// ✅ this is good// since promise is stable across all rendersfunction Component() {// ...}
Question: are we going to see the warning if we use this use()
conditionally?
Nope, as mentioned the thenable array is reset after rendering so even
if we run the use()
conditionally, there won’t be mismatched promises
being detected.
If I’m right about this, the check is supposed to work in Strict Mode to make sure promise is externalized outside of components.
3.1 caveat: unable to unwrap promise immediately even if it is fulfilled
Here is a demo of using a Promise that is already resolved. Tick the checkbox
and we can see that there is loading indicator even if it is initialized as
const promise = Promise.resolve('JSer.dev')
The problem is more of Promise itself, we are unable to check
its internal states other than attaching callbacks
to then()
or catch()
- both are async.
Looking at the internals of useThenable()
, there is
actually another round of check on thenable.status
after callbacks
are setup, so we are actually able to hack then()
like below.
const promise = Promise.resolve('hello');promise.then = (...args) => {promise.status = 'fulfilled';promise.value = 'hello';Trick React to know the fulfilled state earlier
return Promise.prototype.then.call(promise, ...args);};
const promise = Promise.resolve('hello');promise.then = (...args) => {promise.status = 'fulfilled';promise.value = 'hello';Trick React to know the fulfilled state earlier
return Promise.prototype.then.call(promise, ...args);};
With above code, the reveal of Promise value is going to be instant!
4. use()
context is equivalent to useContext()
This is simple, ues()
uses readContext()
internally
and useContext()
is just an alias for readContext()
.
function use<T>(usable: Usable<T>): T {if (usable !== null && typeof usable === 'object') {if (typeof usable.then === 'function') {const thenable: Thenable<T> = (usable: any);return useThenable(thenable);} else if (usable.$$typeof === REACT_CONTEXT_TYPE) {const context: ReactContext<T> = (usable: any);return readContext(context);}}throw new Error('An unsupported type was passed to use(): ' + String(usable));}export function readContext<T>(context: ReactContext<T>): T {return readContextForConsumer(currentlyRenderingFiber, context);}...const HooksDispatcherOnMount: Dispatcher = {readContext,use,useCallback: mountCallback,useContext: readContext,}
function use<T>(usable: Usable<T>): T {if (usable !== null && typeof usable === 'object') {if (typeof usable.then === 'function') {const thenable: Thenable<T> = (usable: any);return useThenable(thenable);} else if (usable.$$typeof === REACT_CONTEXT_TYPE) {const context: ReactContext<T> = (usable: any);return readContext(context);}}throw new Error('An unsupported type was passed to use(): ' + String(usable));}export function readContext<T>(context: ReactContext<T>): T {return readContextForConsumer(currentlyRenderingFiber, context);}...const HooksDispatcherOnMount: Dispatcher = {readContext,use,useCallback: mountCallback,useContext: readContext,}
For more about context, refer to my post on how does Context work internally in React.
5. use()
could be called conditionally
Most useXXX()
hooks store and access data from a Fiber node,
and the data points form a linked list and hooked to that Fiber.
Take useState()
as an example. As explained in
how does useState() works internally,
there is mountWorkInProgressHook()
and updateWorkInProgressHook()
to
set and get the right data from the linked list, thus call order are important for useState()
.
But for useThenable()
the data is attached to the promise
itself,
and for readContext()
the data is from its closest ancestor Fiber node
of the Context Provider.
This is why use()
could be called conditionally. useContext()
could too, though
linter rule warns about it.
OK, so
— jser (@JSer_ZANP) March 16, 2024useContext()
is different from otheruseXXX
hooks, it doesn’t read data from its call order in the component but rather from its closest ancestor component which sets the context value.
Though lint rules say it cannot be used conditionally, but in fact, it is fine. https://t.co/IKWEXo6o0M
6. Summary
use()
internally is quite simple, we can actually implement
something similar(thought unable to do achieve exactly the same).
Personally I’m not sure if it is a good idea to overload this use()
to support Promise and context at the same time(maybe there
are going to be more data types supported), because they are totally
different internally.
How do you like use()
? Hope this post helps you understand React internals better.
Want to know more about how React works internally?
Check out my series - React Internals Deep Dive!