How does useOptimistic() work internally in React?
useOptimistic()
is still experimental and will be ready for upcoming React@19, this post is based on commit@1293047. To understand this hook more easily, I’d recommend following posts to be checked first.
Optimistic UI is a common trick in UI development to
improve the perceived performance.
useOptimistic()
is a hook to
help build it. Let’s figure out how it work internally.
1. What is Optimistic UI?
Suppose we are to implement a “like” button that users can click to like or unlike, how would you do it?
The basic idea is simple, we fetch the API when button is clicked and update the UI upon API response. This works but it could be very laggy, give it a try at following demo.
Here is the click handler.
const toggle = () => {if (isFetching) returnsetIsFetching(true)mutateAPI({liked: !liked}, forceResult).then((data) => {setLiked(data.liked)Update state upon API response
}).catch((reason) => {toast(reason)}).finally(() => {setIsFetching(false)})}
const toggle = () => {if (isFetching) returnsetIsFetching(true)mutateAPI({liked: !liked}, forceResult).then((data) => {setLiked(data.liked)Update state upon API response
}).catch((reason) => {toast(reason)}).finally(() => {setIsFetching(false)})}
We want to provide users instant feedback but that is technically impossible because of the round-trip of API requests, unless we update the UI without waiting the API response. This is actually good because most of the time we’d expect the API fetch to succeed.
So in the click handler we can:
- update the UI instantly
- trigger the API call
- on API response(success or error), update the UI again.
Below is a demo with Optimistic UI enabled, which has a huge boost in perceived performance.
The change is as below.
const [liked, setLiked] = useState(false)const [optimisticLiked, setOptimisticLiked] = useState(liked)We need to store the true state and the optimistic state
Separately to handle race condition
// this better be properly handled by AbortController// for demo purpose,// here I used a unique call id to only trust the last api callconst callIdRef = useRef(null)const toggle = () => {setOptimisticLiked(!optimisticLiked)const callId = uid()callIdRef.current = callIdmutateAPI({liked: !optimisticLiked}, forceResult).then((data) => {if (callIdRef.current === callId) {setOptimisticLiked(data.liked)setLiked(data.liked)}Discard the request except the last one
}).catch((reason) => {if (callIdRef.current === callId) {setOptimisticLiked(liked)}toast(reason)})}
const [liked, setLiked] = useState(false)const [optimisticLiked, setOptimisticLiked] = useState(liked)We need to store the true state and the optimistic state
Separately to handle race condition
// this better be properly handled by AbortController// for demo purpose,// here I used a unique call id to only trust the last api callconst callIdRef = useRef(null)const toggle = () => {setOptimisticLiked(!optimisticLiked)const callId = uid()callIdRef.current = callIdmutateAPI({liked: !optimisticLiked}, forceResult).then((data) => {if (callIdRef.current === callId) {setOptimisticLiked(data.liked)setLiked(data.liked)}Discard the request except the last one
}).catch((reason) => {if (callIdRef.current === callId) {setOptimisticLiked(liked)}toast(reason)})}
Above code is for demo purpose, it is NOT bug-free.
Consecutive clicking/Race condition needs to be handled properly in both approaches. You can see that it is actually very tricky to get it right. In the first demo, a loading state is added to prevent multiple ongoing requests. In the second demo, requests except the last one are discarded.
2. Data-fetching libraries usually has built-in support for Optimistic UI.
Looking at our code, Optimistic UI is all about updating the state before API request. So it feels natural to have optimistic update supported in data-fetching libraries.
Relay supports this by optimisticUpdater()
in mutation.
function StoryLikeButton({story}) {...function onLikeButtonClicked(newDoesLike) {commitMutation({variables: {id: data.id,doesViewerLike: newDoesLike,},optimisticUpdater: store => {// TODO fill in optimistic updater},})}...}
function StoryLikeButton({story}) {...function onLikeButtonClicked(newDoesLike) {commitMutation({variables: {id: data.id,doesViewerLike: newDoesLike,},optimisticUpdater: store => {// TODO fill in optimistic updater},})}...}
swr also
supports it through optimisticData
option in its mutate()
.
<buttontype="submit"onClick={async () => {setText("");const newTodo = {id: Date.now(),text};try {// Update the local state immediately and fire the// request. Since the API will return the updated// data, there is no need to start a new revalidation// and we can directly populate the cache.await mutate(addTodo(newTodo), {optimisticData: [...data, newTodo],rollbackOnError: true,populateCache: true,revalidate: false});toast.success("Successfully added the new item.");} catch (e) {// If the API errors, the original data will be// rolled back by SWR automatically.toast.error("Failed to add the new item.");}}}>Add</button>
<buttontype="submit"onClick={async () => {setText("");const newTodo = {id: Date.now(),text};try {// Update the local state immediately and fire the// request. Since the API will return the updated// data, there is no need to start a new revalidation// and we can directly populate the cache.await mutate(addTodo(newTodo), {optimisticData: [...data, newTodo],rollbackOnError: true,populateCache: true,revalidate: false});toast.success("Successfully added the new item.");} catch (e) {// If the API errors, the original data will be// rolled back by SWR automatically.toast.error("Failed to add the new item.");}}}>Add</button>
We can rewrite our demo of like button by creating an API wrapper that supports optimistic updater.
import {useState, useRef, useEffect} from 'react'export function useAPI(fetcher) {const [state, setState] = useState({})const [optimisticState, setOptimisticState] = useState(state)const mutateIdRef = useRef(null)useEffect(() => {}, [])const mutate = (updater, options = {}) => {const mutateId = uid()mutateIdRef.current = mutateIdif (options.optimisticData) {setOptimisticState(state => ({...optimisticState,data: options.optimisticData}))}Handle optimistic update
updater().then((data) => {if (mutateIdRef.current === mutateId) {setState(state => ({...state,data}))setOptimisticState(state => ({...optimisticState,data}))}}).catch((error) => {if (options.onError) {options.onError(error)}if (mutateIdRef.current === mutateId && options.rollbackOnError) {setOptimisticState(state)Rollback to the true state
}})}return {data: optimisticState.data,error: state.error,mutate}}const uid = (() => {let i = 0return () => i++})()
import {useState, useRef, useEffect} from 'react'export function useAPI(fetcher) {const [state, setState] = useState({})const [optimisticState, setOptimisticState] = useState(state)const mutateIdRef = useRef(null)useEffect(() => {}, [])const mutate = (updater, options = {}) => {const mutateId = uid()mutateIdRef.current = mutateIdif (options.optimisticData) {setOptimisticState(state => ({...optimisticState,data: options.optimisticData}))}Handle optimistic update
updater().then((data) => {if (mutateIdRef.current === mutateId) {setState(state => ({...state,data}))setOptimisticState(state => ({...optimisticState,data}))}}).catch((error) => {if (options.onError) {options.onError(error)}if (mutateIdRef.current === mutateId && options.rollbackOnError) {setOptimisticState(state)Rollback to the true state
}})}return {data: optimisticState.data,error: state.error,mutate}}const uid = (() => {let i = 0return () => i++})()
Here is the demo with our new useAPI()
.
The demo looks good, and the button component becomes even cleaner.
const {data, mutate} = useAPI(() => Promise.resolve({liked: false}))const liked = !!data?.likedconst toggle = () => {const payload = {liked: !liked}mutate(() => mutateAPI(payload, forceResult),{optimisticData: payload,rollbackOnError: true,onError: toast})}
const {data, mutate} = useAPI(() => Promise.resolve({liked: false}))const liked = !!data?.likedconst toggle = () => {const payload = {liked: !liked}mutate(() => mutateAPI(payload, forceResult),{optimisticData: payload,rollbackOnError: true,onError: toast})}
Great, enough background for Optimistic UI. Now let’s dive into React world. 🤿
3. Try to implement useOptimistic()
by ourselves.
Here is the official demo of useOptimistic()
.
const [messages, setMessages] = useState([{ text: "Hello there!", sending: false, key: 1 }]);Source of truth for the state when stable
async function sendMessage(formData) {const sentMessage = await deliverMessage(formData.get("message"));setMessages((messages) => [...messages, { text: sentMessage }]);}const [optimisticMessages, addOptimisticMessage] = useOptimistic(messages,(state, newMessage) => [...state,{text: newMessage,sending: true}]);A copy of the state that we can modify optimistically
async function formAction(formData) {addOptimisticMessage(formData.get("message"));Update optimistic state
formRef.current.reset();await sendMessage(formData);Trigger the mutation API
}<form action={formAction} ref={formRef}><input type="text" name="message" placeholder="Hello!" /><button type="submit">Send</button></form>
const [messages, setMessages] = useState([{ text: "Hello there!", sending: false, key: 1 }]);Source of truth for the state when stable
async function sendMessage(formData) {const sentMessage = await deliverMessage(formData.get("message"));setMessages((messages) => [...messages, { text: sentMessage }]);}const [optimisticMessages, addOptimisticMessage] = useOptimistic(messages,(state, newMessage) => [...state,{text: newMessage,sending: true}]);A copy of the state that we can modify optimistically
async function formAction(formData) {addOptimisticMessage(formData.get("message"));Update optimistic state
formRef.current.reset();await sendMessage(formData);Trigger the mutation API
}<form action={formAction} ref={formRef}><input type="text" name="message" placeholder="Hello!" /><button type="submit">Send</button></form>
In our implementation of Optimistic UI, the optimistic state is overwritten by the actual state when the API response is received.
From above code, we can assume that the optimistic state
is reset when the state arg(messages
) changes. This is some
ability we don’t have in useState()
or useReducer()
-
we cannot alter the state directly without setState()
.
If I’m asked to implement something similar, probably it’ll end up in following code.
function useOptimistic(state, optimisticDispatcher) {const [optimisticState, setState] = useState(state)useLayoutEffect(() => {setState(optimisticState)}, [state])We can only update the state through setState()
const dispatch = (action) => {setState((state) => optimisticDispatcher(state, action))}return [optimisticState,dispatch]}
function useOptimistic(state, optimisticDispatcher) {const [optimisticState, setState] = useState(state)useLayoutEffect(() => {setState(optimisticState)}, [state])We can only update the state through setState()
const dispatch = (action) => {setState((state) => optimisticDispatcher(state, action))}return [optimisticState,dispatch]}
Here is the official demo with the useOptimistic()
replaced with our
implementation.
If you try out above demo, you’ll notice it doesn’t work actually - the optimistic
update is not rendered at all! The problem is the action
on <form/>
!
If we switch to old-school onSubmit()
it kinda works.
async function formAction(e) {e.preventDefault()const formData = new FormData(formRef.current)addOptimisticMessage(formData.get("message"));formRef.current.reset();await sendMessage(formData);}<form onSubmit={formAction} ref={formRef}><input type="text" name="message" placeholder="Hello!" /><button type="submit">Send</button></form>
async function formAction(e) {e.preventDefault()const formData = new FormData(formRef.current)addOptimisticMessage(formData.get("message"));formRef.current.reset();await sendMessage(formData);}<form onSubmit={formAction} ref={formRef}><input type="text" name="message" placeholder="Hello!" /><button type="submit">Send</button></form>
I said it “kinda works” because it actually doesn’t quite work the same
as useOptimistic()
- try entering multiple messages quickly and you’ll
notice the difference from above demo with the official one.
4. How does useOptimistic()
work internally in React?
The internals of useOptimistic()
is quite complex. Let’s first look
at the official summary.
It accepts some state as an argument and returns a copy of that state that can be different during the duration of an async action such as a network request. You provide a function that takes the current state and the input to the action, and returns the optimistic state to be used while the action is pending.
So useOptimistic()
is supposed to be used under async action, and it must be related to transition.
For more about transitions, refer to How does useTransition() work internally in React?.
4.1 mountOptimistic()
As always, let’s start with the initial mount.
function mountOptimistic<S, A>(passthrough: S,reducer: ?(S, A) => S,): [S, (A) => void] {const hook = mountWorkInProgressHook();hook.memoizedState = hook.baseState = passthrough;const queue: UpdateQueue<S, A> = {pending: null,lanes: NoLanes,dispatch: null,// Optimistic state does not use the eager update optimization.lastRenderedReducer: null,lastRenderedState: null,};hook.queue = queue;// This is different than the normal setState function.const dispatch: A => void = (dispatchOptimisticSetState.bind(null,currentlyRenderingFiber,true,queue,): any);queue.dispatch = dispatch;return [passthrough, dispatch];basically the same as
useState()
!}function dispatchOptimisticSetState<S, A>(fiber: Fiber,throwIfDuringRender: boolean,queue: UpdateQueue<S, A>,action: A,): void {const transition = requestCurrentTransition();const update: Update<S, A> = {// An optimistic update commits synchronously.lane: SyncLane,in
useState()
this lane is determined byrequestUpdateLane()
, whichcould be any priority
But here it uses only
SyncLane
. I guess it is becauseuseOptimistic() is supposed to be used in async action, if not set
to be SynLane, it will be transition lanes and never be committed
// After committing, the optimistic update is "reverted" using the same// lane as the transition it's associated with.revertLane: requestTransitionLane(transition),
revertLane
only exists inuseOptimistic()
. It is to tell Reactthat when
revertLane
is be worked on, the optimistic update shouldbe reverted.
action,hasEagerState: false,eagerState: null,next: (null: any),};if (isRenderPhaseUpdate(fiber)) {} else {const root = enqueueConcurrentHookUpdate(fiber, queue, update, SyncLane);Same as
useState()
, it enqueues the updateif (root !== null) {// NOTE: The optimistic update implementation assumes that the transition// will never be attempted before the optimistic update. This currently// holds because the optimistic update is always synchronous. If we ever// change that, we'll need to account for this.scheduleUpdateOnFiber(root, fiber, SyncLane);Now rerender on SyncLane is scheduled
// Optimistic updates are always synchronous, so we don't need to call// entangleTransitionUpdate here.}}}
function mountOptimistic<S, A>(passthrough: S,reducer: ?(S, A) => S,): [S, (A) => void] {const hook = mountWorkInProgressHook();hook.memoizedState = hook.baseState = passthrough;const queue: UpdateQueue<S, A> = {pending: null,lanes: NoLanes,dispatch: null,// Optimistic state does not use the eager update optimization.lastRenderedReducer: null,lastRenderedState: null,};hook.queue = queue;// This is different than the normal setState function.const dispatch: A => void = (dispatchOptimisticSetState.bind(null,currentlyRenderingFiber,true,queue,): any);queue.dispatch = dispatch;return [passthrough, dispatch];basically the same as
useState()
!}function dispatchOptimisticSetState<S, A>(fiber: Fiber,throwIfDuringRender: boolean,queue: UpdateQueue<S, A>,action: A,): void {const transition = requestCurrentTransition();const update: Update<S, A> = {// An optimistic update commits synchronously.lane: SyncLane,in
useState()
this lane is determined byrequestUpdateLane()
, whichcould be any priority
But here it uses only
SyncLane
. I guess it is becauseuseOptimistic() is supposed to be used in async action, if not set
to be SynLane, it will be transition lanes and never be committed
// After committing, the optimistic update is "reverted" using the same// lane as the transition it's associated with.revertLane: requestTransitionLane(transition),
revertLane
only exists inuseOptimistic()
. It is to tell Reactthat when
revertLane
is be worked on, the optimistic update shouldbe reverted.
action,hasEagerState: false,eagerState: null,next: (null: any),};if (isRenderPhaseUpdate(fiber)) {} else {const root = enqueueConcurrentHookUpdate(fiber, queue, update, SyncLane);Same as
useState()
, it enqueues the updateif (root !== null) {// NOTE: The optimistic update implementation assumes that the transition// will never be attempted before the optimistic update. This currently// holds because the optimistic update is always synchronous. If we ever// change that, we'll need to account for this.scheduleUpdateOnFiber(root, fiber, SyncLane);Now rerender on SyncLane is scheduled
// Optimistic updates are always synchronous, so we don't need to call// entangleTransitionUpdate here.}}}
So generally for the initial mount, updateOptimistic()
is not that different
from useState()
. The major difference is that its update has revertLane
.
If rendering lane is NOT revertLane
, then the update should be committed. Otherwise, it
should be reverted.
4.2 updateOptimistic()
We’ll find how revertLane
works in the renders after initial mount.
function updateOptimistic<S, A>(passthrough: S,reducer: ?(S, A) => S,): [S, (A) => void] {const hook = updateWorkInProgressHook();return updateOptimisticImpl(hook,((currentHook: any): Hook),passthrough,reducer,);}function updateOptimisticImpl<S, A>(hook: Hook,current: Hook | null,passthrough: S,reducer: ?(S, A) => S,): [S, (A) => void] {// Optimistic updates are always rebased on top of the latest value passed in// as an argument. It's called a passthrough because if there are no pending// updates, it will be returned as-is.//// Reset the base state to the passthrough. Future updates will be applied// on top of this.hook.baseState = passthrough;This is different than
useState()
. It resets the base stateWhich addresses the issue we mentioned - unable to update the state
other than setState()
// If a reducer is not provided, default to the same one used by useState.const resolvedReducer: (S, A) => S =typeof reducer === 'function' ? reducer : (basicStateReducer: any);return updateReducerImpl(hook, ((currentHook: any): Hook), resolvedReducer);Again, it is pretty much the same as
useState()
}
function updateOptimistic<S, A>(passthrough: S,reducer: ?(S, A) => S,): [S, (A) => void] {const hook = updateWorkInProgressHook();return updateOptimisticImpl(hook,((currentHook: any): Hook),passthrough,reducer,);}function updateOptimisticImpl<S, A>(hook: Hook,current: Hook | null,passthrough: S,reducer: ?(S, A) => S,): [S, (A) => void] {// Optimistic updates are always rebased on top of the latest value passed in// as an argument. It's called a passthrough because if there are no pending// updates, it will be returned as-is.//// Reset the base state to the passthrough. Future updates will be applied// on top of this.hook.baseState = passthrough;This is different than
useState()
. It resets the base stateWhich addresses the issue we mentioned - unable to update the state
other than setState()
// If a reducer is not provided, default to the same one used by useState.const resolvedReducer: (S, A) => S =typeof reducer === 'function' ? reducer : (basicStateReducer: any);return updateReducerImpl(hook, ((currentHook: any): Hook), resolvedReducer);Again, it is pretty much the same as
useState()
}
A state hook holds a list of updates in different priorities, and they are processed in different batches, so following properties are needed.
baseQueue
: all the updatesbaseState
: the base state for the updates, because updates processed in order like middlewares.memoizedState
: the actual value to be used in rendering. This value is intermediate if there are pending updates.
updateReducer()
works, refer to How does useState() work internally in React? This explains why we need to update baseState
in updateOptimisticImpl()
.
Because the updates in the queue are supposed to be optimistic, meaning they
should be intermediate state, if we put a
critical update after these updates, it leads to be wrong state.
function updateReducerImpl<S, A>(This is the same as
useState()
, which internally is the same asuseReducer()
hook: Hook,current: Hook,reducer: (S, A) => S,): [S, Dispatch<A>] {const baseState = hook.baseState;if (baseQueue === null) {} else {// We have a queue to process.const first = baseQueue.next;let newState = baseState;let newBaseState = null;let newBaseQueueFirst = null;let newBaseQueueLast: Update<S, A> | null = null;let update = first;let didReadFromEntangledAsyncAction = false;do {Updates are processed in this loop
// An extra OffscreenLane bit is added to updates that were made to// a hidden tree, so that we can distinguish them from updates that were// already there when the tree was hidden.const updateLane = removeLanes(update.lane, OffscreenLane);const isHiddenUpdate = updateLane !== update.lane;// Check if this update was made while the tree was hidden. If so, then// it's not a "base" update and we should disregard the extra base lanes// that were added to renderLanes when we entered the Offscreen tree.const shouldSkipUpdate = isHiddenUpdate? !isSubsetOfLanes(getWorkInProgressRootRenderLanes(), updateLane): !isSubsetOfLanes(renderLanes, updateLane);This line is important, it says if current rendering lane is not the
lane of update, then we should skip the update.
if (shouldSkipUpdate) {// Priority is insufficient. Skip this update. If this is the first// skipped update, the previous update/state is the new base// update/state.const clone: Update<S, A> = {lane: updateLane,revertLane: update.revertLane,action: update.action,hasEagerState: update.hasEagerState,eagerState: update.eagerState,next: (null: any),};if (newBaseQueueLast === null) {newBaseQueueFirst = newBaseQueueLast = clone;newBaseState = newState;} else {newBaseQueueLast = newBaseQueueLast.next = clone;}// Update the remaining priority in the queue.// TODO: Don't need to accumulate this. Instead, we can remove// renderLanes from the original lanes.currentlyRenderingFiber.lanes = mergeLanes(currentlyRenderingFiber.lanes,updateLane,);markSkippedUpdateLanes(updateLane);Skip doesn't mean ignore, the update is cloned and put in the queue for next render
} else {// This update does have sufficient priority.// Check if this is an optimistic update.const revertLane = update.revertLane;if (!enableAsyncActions || revertLane === NoLane) {If NOT optimistic update
// This is not an optimistic update, and we're going to apply it now.// But, if there were earlier updates that were skipped, we need to// leave this update in the queue so it can be rebased later.if (newBaseQueueLast !== null) {const clone: Update<S, A> = {// This update is going to be committed so we never want uncommit// it. Using NoLane works because 0 is a subset of all bitmasks, so// this will never be skipped by the check above.lane: NoLane,revertLane: NoLane,action: update.action,hasEagerState: update.hasEagerState,eagerState: update.eagerState,next: (null: any),};newBaseQueueLast = newBaseQueueLast.next = clone;}// Check if this update is part of a pending async action. If so,// we'll need to suspend until the action has finished, so that it's// batched together with future updates in the same action.if (updateLane === peekEntangledActionLane()) {didReadFromEntangledAsyncAction = true;}If the update lane is same as current transition lane
then this flag is to mark the state change as depending on the async action
This flag is consumed at the end of this function
} else {Optimistic update since there is
revertLane
// This is an optimistic update. If the "revert" priority is// sufficient, don't apply the update. Otherwise, apply the update,// but leave it in the queue so it can be either reverted or// rebased in a subsequent render.if (isSubsetOfLanes(renderLanes, revertLane)) {---------------------------------------As mentioned, if revertLane is current rendering lane
It means we need to revert the update
// The transition that this optimistic update is associated with// has finished. Pretend the update doesn't exist by skipping// over it.update = update.next;// Check if this update is part of a pending async action. If so,// we'll need to suspend until the action has finished, so that it's// batched together with future updates in the same action.if (revertLane === peekEntangledActionLane()) {didReadFromEntangledAsyncAction = true;}continue;Here it is reverted by simply ignored and NOT put in the next queue]
It is different from the skipping because in skipping
The update is still cloned and put in the next queue
} else {If not optimistic update, then we need to commit it!
But we still need to put it in the queue.
Because until the async action is complete, we need to be able
to set optimistic update! So optimistic updates must be in the queue
during the async action
const clone: Update<S, A> = {// Once we commit an optimistic update, we shouldn't uncommit it// until the transition it is associated with has finished// (represented by revertLane). Using NoLane here works because 0// is a subset of all bitmasks, so this will never be skipped by// the check above.lane: NoLane,Setting it to NoLane means it won't be skipped in following renders
// Reuse the same revertLane so we know when the transition// has finished.revertLane: update.revertLane,action: update.action,hasEagerState: update.hasEagerState,eagerState: update.eagerState,next: (null: any),};if (newBaseQueueLast === null) {newBaseQueueFirst = newBaseQueueLast = clone;newBaseState = newState;} else {newBaseQueueLast = newBaseQueueLast.next = clone;}Chain up the updates for new render
// Update the remaining priority in the queue.// TODO: Don't need to accumulate this. Instead, we can remove// renderLanes from the original lanes.currentlyRenderingFiber.lanes = mergeLanes(currentlyRenderingFiber.lanes,revertLane,);markSkippedUpdateLanes(revertLane);}}// Process this update.const action = update.action;if (shouldDoubleInvokeUserFnsInHooksDEV) {reducer(newState, action);}if (update.hasEagerState) {// If this update is a state update (not a reducer) and was processed eagerly,// we can use the eagerly computed statenewState = ((update.eagerState: any): S);} else {newState = reducer(newState, action);Update is committed here
}}update = update.next;} while (update !== null && update !== first);if (newBaseQueueLast === null) {newBaseState = newState;} else {newBaseQueueLast.next = (newBaseQueueFirst: any);}// Mark that the fiber performed work, but only if the new state is// different from the current state.if (!is(newState, hook.memoizedState)) {markWorkInProgressReceivedUpdate();// Check if this update is part of a pending async action. If so, we'll// need to suspend until the action has finished, so that it's batched// together with future updates in the same action.// TODO: Once we support hooks inside useMemo (or an equivalent// memoization boundary like Forget), hoist this logic so that it only// suspends if the memo boundary produces a new value.if (didReadFromEntangledAsyncAction) {const entangledActionThenable = peekEntangledActionThenable();if (entangledActionThenable !== null) {// TODO: Instead of the throwing the thenable directly, throw a// special object like `use` does so we can detect if it's captured// by userspace.throw entangledActionThenable;}}This part is most important
If the state is changed and it depends on an ongoing async action
then it suspends by throwing the promise.
For more details about Suspense, refer to my post about Suspense
}hook.memoizedState = newState;hook.baseState = newBaseState;hook.baseQueue = newBaseQueueLast;Finally update the hook. Notice
baseQueue
is updated as well.queue.lastRenderedState = newState;}if (baseQueue === null) {// `queue.lanes` is used for entangling transitions. We can set it back to// zero once the queue is empty.queue.lanes = NoLanes;}const dispatch: Dispatch<A> = (queue.dispatch: any);return [hook.memoizedState, dispatch];}
function updateReducerImpl<S, A>(This is the same as
useState()
, which internally is the same asuseReducer()
hook: Hook,current: Hook,reducer: (S, A) => S,): [S, Dispatch<A>] {const baseState = hook.baseState;if (baseQueue === null) {} else {// We have a queue to process.const first = baseQueue.next;let newState = baseState;let newBaseState = null;let newBaseQueueFirst = null;let newBaseQueueLast: Update<S, A> | null = null;let update = first;let didReadFromEntangledAsyncAction = false;do {Updates are processed in this loop
// An extra OffscreenLane bit is added to updates that were made to// a hidden tree, so that we can distinguish them from updates that were// already there when the tree was hidden.const updateLane = removeLanes(update.lane, OffscreenLane);const isHiddenUpdate = updateLane !== update.lane;// Check if this update was made while the tree was hidden. If so, then// it's not a "base" update and we should disregard the extra base lanes// that were added to renderLanes when we entered the Offscreen tree.const shouldSkipUpdate = isHiddenUpdate? !isSubsetOfLanes(getWorkInProgressRootRenderLanes(), updateLane): !isSubsetOfLanes(renderLanes, updateLane);This line is important, it says if current rendering lane is not the
lane of update, then we should skip the update.
if (shouldSkipUpdate) {// Priority is insufficient. Skip this update. If this is the first// skipped update, the previous update/state is the new base// update/state.const clone: Update<S, A> = {lane: updateLane,revertLane: update.revertLane,action: update.action,hasEagerState: update.hasEagerState,eagerState: update.eagerState,next: (null: any),};if (newBaseQueueLast === null) {newBaseQueueFirst = newBaseQueueLast = clone;newBaseState = newState;} else {newBaseQueueLast = newBaseQueueLast.next = clone;}// Update the remaining priority in the queue.// TODO: Don't need to accumulate this. Instead, we can remove// renderLanes from the original lanes.currentlyRenderingFiber.lanes = mergeLanes(currentlyRenderingFiber.lanes,updateLane,);markSkippedUpdateLanes(updateLane);Skip doesn't mean ignore, the update is cloned and put in the queue for next render
} else {// This update does have sufficient priority.// Check if this is an optimistic update.const revertLane = update.revertLane;if (!enableAsyncActions || revertLane === NoLane) {If NOT optimistic update
// This is not an optimistic update, and we're going to apply it now.// But, if there were earlier updates that were skipped, we need to// leave this update in the queue so it can be rebased later.if (newBaseQueueLast !== null) {const clone: Update<S, A> = {// This update is going to be committed so we never want uncommit// it. Using NoLane works because 0 is a subset of all bitmasks, so// this will never be skipped by the check above.lane: NoLane,revertLane: NoLane,action: update.action,hasEagerState: update.hasEagerState,eagerState: update.eagerState,next: (null: any),};newBaseQueueLast = newBaseQueueLast.next = clone;}// Check if this update is part of a pending async action. If so,// we'll need to suspend until the action has finished, so that it's// batched together with future updates in the same action.if (updateLane === peekEntangledActionLane()) {didReadFromEntangledAsyncAction = true;}If the update lane is same as current transition lane
then this flag is to mark the state change as depending on the async action
This flag is consumed at the end of this function
} else {Optimistic update since there is
revertLane
// This is an optimistic update. If the "revert" priority is// sufficient, don't apply the update. Otherwise, apply the update,// but leave it in the queue so it can be either reverted or// rebased in a subsequent render.if (isSubsetOfLanes(renderLanes, revertLane)) {---------------------------------------As mentioned, if revertLane is current rendering lane
It means we need to revert the update
// The transition that this optimistic update is associated with// has finished. Pretend the update doesn't exist by skipping// over it.update = update.next;// Check if this update is part of a pending async action. If so,// we'll need to suspend until the action has finished, so that it's// batched together with future updates in the same action.if (revertLane === peekEntangledActionLane()) {didReadFromEntangledAsyncAction = true;}continue;Here it is reverted by simply ignored and NOT put in the next queue]
It is different from the skipping because in skipping
The update is still cloned and put in the next queue
} else {If not optimistic update, then we need to commit it!
But we still need to put it in the queue.
Because until the async action is complete, we need to be able
to set optimistic update! So optimistic updates must be in the queue
during the async action
const clone: Update<S, A> = {// Once we commit an optimistic update, we shouldn't uncommit it// until the transition it is associated with has finished// (represented by revertLane). Using NoLane here works because 0// is a subset of all bitmasks, so this will never be skipped by// the check above.lane: NoLane,Setting it to NoLane means it won't be skipped in following renders
// Reuse the same revertLane so we know when the transition// has finished.revertLane: update.revertLane,action: update.action,hasEagerState: update.hasEagerState,eagerState: update.eagerState,next: (null: any),};if (newBaseQueueLast === null) {newBaseQueueFirst = newBaseQueueLast = clone;newBaseState = newState;} else {newBaseQueueLast = newBaseQueueLast.next = clone;}Chain up the updates for new render
// Update the remaining priority in the queue.// TODO: Don't need to accumulate this. Instead, we can remove// renderLanes from the original lanes.currentlyRenderingFiber.lanes = mergeLanes(currentlyRenderingFiber.lanes,revertLane,);markSkippedUpdateLanes(revertLane);}}// Process this update.const action = update.action;if (shouldDoubleInvokeUserFnsInHooksDEV) {reducer(newState, action);}if (update.hasEagerState) {// If this update is a state update (not a reducer) and was processed eagerly,// we can use the eagerly computed statenewState = ((update.eagerState: any): S);} else {newState = reducer(newState, action);Update is committed here
}}update = update.next;} while (update !== null && update !== first);if (newBaseQueueLast === null) {newBaseState = newState;} else {newBaseQueueLast.next = (newBaseQueueFirst: any);}// Mark that the fiber performed work, but only if the new state is// different from the current state.if (!is(newState, hook.memoizedState)) {markWorkInProgressReceivedUpdate();// Check if this update is part of a pending async action. If so, we'll// need to suspend until the action has finished, so that it's batched// together with future updates in the same action.// TODO: Once we support hooks inside useMemo (or an equivalent// memoization boundary like Forget), hoist this logic so that it only// suspends if the memo boundary produces a new value.if (didReadFromEntangledAsyncAction) {const entangledActionThenable = peekEntangledActionThenable();if (entangledActionThenable !== null) {// TODO: Instead of the throwing the thenable directly, throw a// special object like `use` does so we can detect if it's captured// by userspace.throw entangledActionThenable;}}This part is most important
If the state is changed and it depends on an ongoing async action
then it suspends by throwing the promise.
For more details about Suspense, refer to my post about Suspense
}hook.memoizedState = newState;hook.baseState = newBaseState;hook.baseQueue = newBaseQueueLast;Finally update the hook. Notice
baseQueue
is updated as well.queue.lastRenderedState = newState;}if (baseQueue === null) {// `queue.lanes` is used for entangling transitions. We can set it back to// zero once the queue is empty.queue.lanes = NoLanes;}const dispatch: Dispatch<A> = (queue.dispatch: any);return [hook.memoizedState, dispatch];}
Let’s summarize the flow here a little bit.
- An optimistic update has
revertLane
of a transition lane andlane
ofSyncLane
- Update is processed in
SyncLane
and also in all following renders 2.1 but it is NOT skipped and always kept in the next update queue - Update is reverted in
revertLane
(low priority transition lane), by NOT getting added to the next queue 3.1 But if the async action on the transition lane is not yet complete, it suspends by throwing the promise. The revert will be tried again after the async action is done.
Our last piece of puzzle is what is the async action and how does it work with transition?
4.3 Some background on transition
Please refer to How does useTransition() work internally in React for more details, simply put
- there is a global
transition
object to indicate if we need to schedule updates in lower priorities(transition lanes) startTransition()
is to set thistransition
object to non-null, so that thesetState()
calls inside its scope schedules low priority updates.
4.4 <form/>
now accepts async action
<form>
here. Only the necessary parts to understand useOptimistic()
, there will be another episode specifically for <form/>
According to React.dev,
<form/>
is no longer a simple HTML tag, it is extended to be able to accept
an async action. In our demo code, it is used like this.
async function formAction(formData) {addOptimisticMessage(formData.get("message"));This updates optimistic state right away
formRef.current.reset();await sendMessage(formData);Trigger the mutation API
}<form action={formAction} ref={formRef}><input type="text" name="message" placeholder="Hello!" /><button type="submit">Send</button></form>
async function formAction(formData) {addOptimisticMessage(formData.get("message"));This updates optimistic state right away
formRef.current.reset();await sendMessage(formData);Trigger the mutation API
}<form action={formAction} ref={formRef}><input type="text" name="message" placeholder="Hello!" /><button type="submit">Send</button></form>
It suggests that the submit behavior of the <form/>
must be somehow intercepted and
yes, it is.
function submitForm() {if (nativeEvent.defaultPrevented) {// We let earlier events to prevent the action from submitting.return;} // Prevent native navigation.event.preventDefault();var formData;if (submitter) {// The submitter's value should be included in the FormData.// It should be in the document order in the form.// Since the FormData constructor invokes the formdata event it also// needs to be available before that happens so after construction it's too// late. We use a temporary fake node for the duration of this event.// TODO: FormData takes a second argument that it's the submitter but this// is fairly new so not all browsers support it yet. Switch to that technique// when available.var temp = submitter.ownerDocument.createElement("input");temp.name = submitter.name;temp.value = submitter.value;submitter.parentNode.insertBefore(temp, submitter);formData = new FormData(form);temp.parentNode.removeChild(temp);} else {formData = new FormData(form);}var pendingState = {pending: true,data: formData,method: form.method,action: action,};{Object.freeze(pendingState);}startHostTransition(formInst, pendingState, action, formData);}
function submitForm() {if (nativeEvent.defaultPrevented) {// We let earlier events to prevent the action from submitting.return;} // Prevent native navigation.event.preventDefault();var formData;if (submitter) {// The submitter's value should be included in the FormData.// It should be in the document order in the form.// Since the FormData constructor invokes the formdata event it also// needs to be available before that happens so after construction it's too// late. We use a temporary fake node for the duration of this event.// TODO: FormData takes a second argument that it's the submitter but this// is fairly new so not all browsers support it yet. Switch to that technique// when available.var temp = submitter.ownerDocument.createElement("input");temp.name = submitter.name;temp.value = submitter.value;submitter.parentNode.insertBefore(temp, submitter);formData = new FormData(form);temp.parentNode.removeChild(temp);} else {formData = new FormData(form);}var pendingState = {pending: true,data: formData,method: form.method,action: action,};{Object.freeze(pendingState);}startHostTransition(formInst, pendingState, action, formData);}
The pendingState
is the future state for the form component. Once
the transition is done, <form/>
will have the internal state in React runtime.
startHostTransition()
is the method to inject state hook into a component.
function startHostTransition(formFiber, pendingState, callback, formData) {var queue;if (formFiber.memoizedState === null) {// Upgrade this host component fiber to be stateful. We're going to pretend// it was stateful all along so we can reuse most of the implementation// for function components and useTransition.//// Create the state hook used by TransitionAwareHostComponent. This is// essentially an inlined version of mountState.var newQueue = {pending: null,lanes: NoLanes,// We're going to cheat and intentionally not create a bound dispatch// method, because we can call it directly in startTransition.dispatch: null,lastRenderedReducer: basicStateReducer,lastRenderedState: NotPendingTransition,};queue = newQueue;var stateHook = {memoizedState: NotPendingTransition,baseState: NotPendingTransition,baseQueue: null,queue: newQueue,next: null,}; // Add the state hook to both fiber alternates. The idea is that the fiber// had this hook all along.formFiber.memoizedState = stateHook;var alternate = formFiber.alternate;if (alternate !== null) {alternate.memoizedState = stateHook;}Since <form/> doesn't have state by default, here it creates
a fresh new state hook for it!
Keep in mind that this is the state for the <form/> itself,
containing info like the pending state.
Its initial state is
NotPendingTransition
and will transition tothe pendingState created in
submitForm()
} else {// This fiber was already upgraded to be stateful.var _stateHook = formFiber.memoizedState;queue = _stateHook.queue;}startTransition(formFiber,queue,pendingState,NotPendingTransition, // TODO: We can avoid this extra wrapper, somehow. Figure out layering// once more of this function is implemented.function () {return callback(formData);This callback is the async
formAction()
, in whichwe trigger the
setOptimisticState()
});Internally startTransition is called
}
function startHostTransition(formFiber, pendingState, callback, formData) {var queue;if (formFiber.memoizedState === null) {// Upgrade this host component fiber to be stateful. We're going to pretend// it was stateful all along so we can reuse most of the implementation// for function components and useTransition.//// Create the state hook used by TransitionAwareHostComponent. This is// essentially an inlined version of mountState.var newQueue = {pending: null,lanes: NoLanes,// We're going to cheat and intentionally not create a bound dispatch// method, because we can call it directly in startTransition.dispatch: null,lastRenderedReducer: basicStateReducer,lastRenderedState: NotPendingTransition,};queue = newQueue;var stateHook = {memoizedState: NotPendingTransition,baseState: NotPendingTransition,baseQueue: null,queue: newQueue,next: null,}; // Add the state hook to both fiber alternates. The idea is that the fiber// had this hook all along.formFiber.memoizedState = stateHook;var alternate = formFiber.alternate;if (alternate !== null) {alternate.memoizedState = stateHook;}Since <form/> doesn't have state by default, here it creates
a fresh new state hook for it!
Keep in mind that this is the state for the <form/> itself,
containing info like the pending state.
Its initial state is
NotPendingTransition
and will transition tothe pendingState created in
submitForm()
} else {// This fiber was already upgraded to be stateful.var _stateHook = formFiber.memoizedState;queue = _stateHook.queue;}startTransition(formFiber,queue,pendingState,NotPendingTransition, // TODO: We can avoid this extra wrapper, somehow. Figure out layering// once more of this function is implemented.function () {return callback(formData);This callback is the async
formAction()
, in whichwe trigger the
setOptimisticState()
});Internally startTransition is called
}
Now things become a bit clearer now.
<form/>
has an async action, when it is submitted, a state hook is created internally for it and a transition is started.- during the
async
action, optimistic state updates are scheduled with therevertLane
being the transition lane - until the async action is complete,
useOptimistic()
suspends every time it tries to revert the changes.
What happens when the async action is complete?
function startTransition(fiber,queue,pendingState,finishedState,callback,options) {var previousPriority = getCurrentUpdatePriority();setCurrentUpdatePriority(higherEventPriority(previousPriority, ContinuousEventPriority));var prevTransition = ReactCurrentBatchConfig$3.transition;var currentTransition = {_callbacks: new Set(),};{// We don't really need to use an optimistic update here, because we// schedule a second "revert" update below (which we use to suspend the// transition until the async action scope has finished). But we'll use an// optimistic update anyway to make it less likely the behavior accidentally// diverges; for example, both an optimistic update and this one should// share the same lane.ReactCurrentBatchConfig$3.transition = currentTransition;As mentioned, here a new global transition is created
dispatchOptimisticSetState(fiber, false, queue, pendingState);This is for the state of the form, not the optimistic state we set.
The state of form is exposed in
useFormStatus()
,and we'll cover it in another episode.
}{ReactCurrentBatchConfig$3.transition._updatedFibers = new Set();}try {if (enableAsyncActions) {this is just a feature flag, it is true in our case
var returnValue = callback();our async
formAction()
is called here, it returns a Promise// Check if we're inside an async action scope. If so, we'll entangle// this new action with the existing scope.//// If we're not already inside an async action scope, and this action is// async, then we'll create a new async scope.//// In the async case, the resulting render will suspend until the async// action scope has finished.if (returnValue !== null &&typeof returnValue === "object" &&typeof returnValue.then === "function") {var thenable = returnValue;notifyTransitionCallbacks(currentTransition, thenable);// Create a thenable that resolves to `finishedState` once the async// action has completed.var thenableForFinishedState = chainThenableValue(thenable,finishedState);dispatchSetState(fiber, queue, thenableForFinishedState);} else {dispatchSetState(fiber, queue, finishedState);}So
<form>
receives a new state update, not optimistic this timeWhy it is NOT optimistic? I think it is because the form state is important
and it should be committed right away
}} catch (error) {}
function startTransition(fiber,queue,pendingState,finishedState,callback,options) {var previousPriority = getCurrentUpdatePriority();setCurrentUpdatePriority(higherEventPriority(previousPriority, ContinuousEventPriority));var prevTransition = ReactCurrentBatchConfig$3.transition;var currentTransition = {_callbacks: new Set(),};{// We don't really need to use an optimistic update here, because we// schedule a second "revert" update below (which we use to suspend the// transition until the async action scope has finished). But we'll use an// optimistic update anyway to make it less likely the behavior accidentally// diverges; for example, both an optimistic update and this one should// share the same lane.ReactCurrentBatchConfig$3.transition = currentTransition;As mentioned, here a new global transition is created
dispatchOptimisticSetState(fiber, false, queue, pendingState);This is for the state of the form, not the optimistic state we set.
The state of form is exposed in
useFormStatus()
,and we'll cover it in another episode.
}{ReactCurrentBatchConfig$3.transition._updatedFibers = new Set();}try {if (enableAsyncActions) {this is just a feature flag, it is true in our case
var returnValue = callback();our async
formAction()
is called here, it returns a Promise// Check if we're inside an async action scope. If so, we'll entangle// this new action with the existing scope.//// If we're not already inside an async action scope, and this action is// async, then we'll create a new async scope.//// In the async case, the resulting render will suspend until the async// action scope has finished.if (returnValue !== null &&typeof returnValue === "object" &&typeof returnValue.then === "function") {var thenable = returnValue;notifyTransitionCallbacks(currentTransition, thenable);// Create a thenable that resolves to `finishedState` once the async// action has completed.var thenableForFinishedState = chainThenableValue(thenable,finishedState);dispatchSetState(fiber, queue, thenableForFinishedState);} else {dispatchSetState(fiber, queue, finishedState);}So
<form>
receives a new state update, not optimistic this timeWhy it is NOT optimistic? I think it is because the form state is important
and it should be committed right away
}} catch (error) {}
It is a bit complex here, there are two updates dispatched.
- an optimistic update for the form state, with action of
js
{action: {action : formAction,data: FormData {},method: "get"pending: true},lane: 2,revertLane: 128}
js
{action: {action : formAction,data: FormData {},method: "get"pending: true},lane: 2,revertLane: 128}
This update will be committed because it is high priority,
and it is kept in the lane to wait for the revertLane
to be worked on.
- another update to resolve the form state.
js
{action: {reason: null,status: 'pending',then: function resolve() {},value: {pending: false, data: null, method: null, action: null}},lane: 128,revertLane: 0}
js
{action: {reason: null,status: 'pending',then: function resolve() {},value: {pending: false, data: null, method: null, action: null}},lane: 128,revertLane: 0}
Because it is normal state update and this update is dispatched under transition,
its update lane is the transition lane and no revertLane
.
Both actions in these two updates are just object, not functions, so the latter one actually replace the first one in reducing.
When the transition lane is worked on, the first update will throw if async action
is still ongoing (But its parent
component <Thread/>
already throws).
When the async action is completed, React re-renders everything. Now the first update doesn’t throw, and the second update is applied, eventually it’ll have a state of below
js
{reason: null,status: 'fulfilled',then: function resolve() {},value: {pending: false, data: null, method: null, action: null}}
js
{reason: null,status: 'fulfilled',then: function resolve() {},value: {pending: false, data: null, method: null, action: null}}
After React commits the changes, the update queue is cleared, we now have a clean slate.
So the first update is actually intermediate, just like the isPending
option in
useTransition()
.
5. Caveat: overlapping optimistic updates
Try to enter multiple messages quickly in the official demo. You’ll notice that the optimistic updates are rendered even after the async action is completed.
It actually happens even if we just enter 1 message, just not that noticeable. The problem
is that when useOptimistic()
receives a new state arg(passthrough), it updates the
base state right away, but the revert of the existing optimistic update is going to be in
the future re-render. In other words, the reverting of optimistic update is in transition
lane(low priority) so it might get interrupted and run in any time in the future.
This is a known issue and React team is working on it. Until it is resolved, we can try to manually filter out the duplicates by updating the dispatcher.
const [optimisticMessages, addOptimisticMessage] = useOptimistic(messages,(state, newMessage) => [...state,...state.filter(msg => msg.text !== newMessage),{text: newMessage,sending: true}]);
const [optimisticMessages, addOptimisticMessage] = useOptimistic(messages,(state, newMessage) => [...state,...state.filter(msg => msg.text !== newMessage),{text: newMessage,sending: true}]);
Now here is a tweaked version of the official demo without the overlapping issue.
6. Summary
That is a lot for useOptimistic()
, because it is built on top of state hooks,
transition and suspense. It is quite complex and I don’t think we should
use it without libraries that has it integrated. It is another use case
to demonstrate the power of concurrent mode.
Below is a brief summary on how useOptimistic()
works. Hope it helps!
<form/>
accepts an async action, when it is submitted,<form/>
will have a new state hook internally, and a global transition is started for the async action until it is completed.useOptimistic()
creates a new state hook which takes a passthrough state, the passthrough state is used as the base state for the updates, this is how the state is updated withoutsetState()
.- When an optimistic update is dispatched, its
revertLane
is set with the transition lane. - When the optimistic update is processed, it is committed right away but still kept in the update queue so that it can be reverted in lower priority(transition lane) later.
- When the optimistic update is reverted in the transition lane, it suspends if the global transition is still ongoing.
Want to know more about how React works internally?
Check out my series - React Internals Deep Dive!