How does useOptimistic() work internally in React?

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.

import { Suspense, useState, use} from 'react';
import {Like} from './Like.js';

export default function App() {
  return <div>
    <h2>non-optimistic UI</h2>
    <p>Clicking following button always succeed</p>
    <Like forceResult="success"/>
    <p>Clicking following button always fail</p>
    <Like forceResult="failure"/>
  </div>
}

const mutateAPI = (payload, successOrFailure) => {
  return new Promise((resolve, reject) => {
    if (successOrFailure === 'success') {
      setTimeout(() => resolve(payload), 1000)
    } else {
      setTimeout(() => reject('something wrong'), 1000)
    }
  })
}

Here is the click handler.

const toggle = () => {
if (isFetching) return
setIsFetching(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) return
setIsFetching(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:

  1. update the UI instantly
  2. trigger the API call
  3. 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.

import { Suspense, useState, use} from 'react';
import {Like} from './Like.js';

export default function App() {
  return <div>
    <h2>optimistic UI</h2>
    <p>Clicking following button always succeed</p>
    <Like forceResult="success"/>
    <p>Clicking following button always fail</p>
    <Like forceResult="failure"/>
  </div>
}

const mutateAPI = (payload, successOrFailure) => {
  return new Promise((resolve, reject) => {
    if (successOrFailure === 'success') {
      setTimeout(() => resolve(payload), 1000)
    } else {
      setTimeout(() => reject('something wrong'), 1000)
    }
  })
}

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 call
const callIdRef = useRef(null)
const toggle = () => {
setOptimisticLiked(!optimisticLiked)
const callId = uid()
callIdRef.current = callId
mutateAPI({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 call
const callIdRef = useRef(null)
const toggle = () => {
setOptimisticLiked(!optimisticLiked)
const callId = uid()
callIdRef.current = callId
mutateAPI({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)
})
}

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().

<button
type="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>
<button
type="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(() => {
{...}
fetcher().then((data) => {
setState(state => ({
...state,
data
}))
setOptimisticState(state => ({
...optimisticState,
data
}))
}).catch((error) => {
setState(state => ({
...state,
error
}))
setOptimisticState(state => ({
...optimisticState,
error
}))
})
}, [])
const mutate = (updater, options = {}) => {
const mutateId = uid()
mutateIdRef.current = mutateId
if (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 = 0
return () => 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(() => {
{...}
fetcher().then((data) => {
setState(state => ({
...state,
data
}))
setOptimisticState(state => ({
...optimisticState,
data
}))
}).catch((error) => {
setState(state => ({
...state,
error
}))
setOptimisticState(state => ({
...optimisticState,
error
}))
})
}, [])
const mutate = (updater, options = {}) => {
const mutateId = uid()
mutateIdRef.current = mutateId
if (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 = 0
return () => i++
})()

Here is the demo with our new useAPI().

import { Suspense, useState, use} from 'react';
import {Like} from './Like.js';

export default function App() {
  return <div>
    <p>Clicking following button always succeed</p>
    <Like forceResult="success"/>
    <p>Clicking following button always fail</p>
    <Like forceResult="failure"/>
  </div>
}

const mutateAPI = (payload, successOrFailure) => {
  return new Promise((resolve, reject) => {
    if (successOrFailure === 'success') {
      setTimeout(() => resolve(payload), 1000)
    } else {
      setTimeout(() => reject('something wrong'), 1000)
    }
  })
}

The demo looks good, and the button component becomes even cleaner.

const {data, mutate} = useAPI(() => Promise.resolve({liked: false}))
const liked = !!data?.liked
const 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?.liked
const 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().

import { useState, useRef, useOptimistic } from "react";
import { deliverMessage } from "./actions.js";

function Thread({ messages, sendMessage }) {
  const formRef = useRef();
  async function formAction(formData) {
    addOptimisticMessage(formData.get("message"));
    formRef.current.reset();
    await sendMessage(formData);
  }
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (state, newMessage) => [
      ...state,
      {
        text: newMessage,
        sending: true
      }
    ]
  );

  return (
    <>
      {optimisticMessages.map((message, index) => (
        <div key={index}>
          {message.text}
          {!!message.sending && <small> (Sending...)</small>}
        </div>
      ))}
      <form action={formAction} ref={formRef}>
        <input type="text" name="message" placeholder="Hello!" />
        <button type="submit">Send</button>
      </form>
    </>
  );
}

export default function App() {
  const [messages, setMessages] = useState([
    { text: "Hello there!", sending: false, key: 1 }
  ]);
  async function sendMessage(formData) {
    const sentMessage = await deliverMessage(formData.get("message"));
    setMessages((messages) => [...messages, { text: sentMessage }]);
  }
  return <Thread messages={messages} sendMessage={sendMessage} />;
}

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.

import { useState, useRef } from "react";
import { deliverMessage } from "./actions.js";
import { useOptimistic } from "./useOptimistic.js";

function Thread({ messages, sendMessage }) {
  const formRef = useRef();
  async function formAction(formData) {
    addOptimisticMessage(formData.get("message"));
    formRef.current.reset();
    await sendMessage(formData);
  }
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (state, newMessage) => [
      ...state,
      {
        text: newMessage,
        sending: true
      }
    ]
  );

  return (
    <>
      {optimisticMessages.map((message, index) => (
        <div key={index}>
          {message.text}
          {!!message.sending && <small> (Sending...)</small>}
        </div>
      ))}
      <form action={formAction} ref={formRef}>
        <input type="text" name="message" placeholder="Hello!" />
        <button type="submit">Send</button>
      </form>
    </>
  );
}

export default function App() {
  const [messages, setMessages] = useState([
    { text: "Hello there!", sending: false, key: 1 }
  ]);
  async function sendMessage(formData) {
    const sentMessage = await deliverMessage(formData.get("message"));
    setMessages((messages) => [...messages, { text: sentMessage }]);
  }
  return <Thread messages={messages} sendMessage={sendMessage} />;
}

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>
import { useState, useRef } from "react";
import { deliverMessage } from "./actions.js";
import { useOptimistic } from "./useOptimistic.js";

function Thread({ messages, sendMessage }) {
  const formRef = useRef();
  async function formAction(e) {
    e.preventDefault()
    const formData = new FormData(formRef.current)
    addOptimisticMessage(formData.get("message"));
    formRef.current.reset();
    await sendMessage(formData);
  }
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (state, newMessage) => [
      ...state,
      {
        text: newMessage,
        sending: true,
      },
    ]
  );

  return (
    <>
      {optimisticMessages.map((message, index) => (
        <div key={index}>
          {message.text}
          {!!message.sending && <small> (Sending...)</small>}
        </div>
      ))}
      <form onSubmit={formAction} ref={formRef}>
        <input type="text" name="message" placeholder="Hello!" />
        <button type="submit">Send</button>
      </form>
    </>
  );
}

export default function App() {
  const [messages, setMessages] = useState([
    { text: "Hello there!", sending: false, key: 1 }
  ]);
  async function sendMessage(formData) {
    const sentMessage = await deliverMessage(formData.get("message"));
    setMessages((messages) => [...messages, { text: sentMessage }]);
  }
  return <Thread messages={messages} sendMessage={sendMessage} />;
}

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 by requestUpdateLane(), which

could be any priority

But here it uses only SyncLane. I guess it is because

useOptimistic() 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 in useOptimistic(). It is to tell React

that when revertLane is be worked on, the optimistic update should

be reverted.

action,
hasEagerState: false,
eagerState: null,
next: (null: any),
};
if (isRenderPhaseUpdate(fiber)) {
{...}
// When calling startTransition during render, this warns instead of
// throwing because throwing would be a breaking change. setOptimisticState
// is a new API so it's OK to throw.
if (throwIfDuringRender) {
throw new Error('Cannot update optimistic state while rendering.');
} else {
// startTransition was called during render. We don't need to do anything
// besides warn here because the render phase update would be overidden by
// the second update, anyway. We can remove this branch and make it throw
// in a future release.
if (__DEV__) {
console.error('Cannot call startTransition while rendering.');
}
}
} else {
const root = enqueueConcurrentHookUpdate(fiber, queue, update, SyncLane);

Same as useState(), it enqueues the update

if (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 by requestUpdateLane(), which

could be any priority

But here it uses only SyncLane. I guess it is because

useOptimistic() 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 in useOptimistic(). It is to tell React

that when revertLane is be worked on, the optimistic update should

be reverted.

action,
hasEagerState: false,
eagerState: null,
next: (null: any),
};
if (isRenderPhaseUpdate(fiber)) {
{...}
// When calling startTransition during render, this warns instead of
// throwing because throwing would be a breaking change. setOptimisticState
// is a new API so it's OK to throw.
if (throwIfDuringRender) {
throw new Error('Cannot update optimistic state while rendering.');
} else {
// startTransition was called during render. We don't need to do anything
// besides warn here because the render phase update would be overidden by
// the second update, anyway. We can remove this branch and make it throw
// in a future release.
if (__DEV__) {
console.error('Cannot call startTransition while rendering.');
}
}
} else {
const root = enqueueConcurrentHookUpdate(fiber, queue, update, SyncLane);

Same as useState(), it enqueues the update

if (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 state

Which 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 state

Which 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.

  1. baseQueue: all the updates
  2. baseState: the base state for the updates, because updates processed in order like middlewares.
  3. memoizedState: the actual value to be used in rendering. This value is intermediate if there are pending updates.

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 as useReducer()

hook: Hook,
current: Hook,
reducer: (S, A) => S,
): [S, Dispatch<A>] {
{...}
const queue = hook.queue;
if (queue === null) {
throw new Error(
'Should have a queue. This is likely a bug in React. Please file an issue.',
);
}
queue.lastRenderedReducer = reducer;
// The last rebase update that is NOT part of the base state.
let baseQueue = hook.baseQueue;
// The last pending update that hasn't been processed yet.
const pendingQueue = queue.pending;
if (pendingQueue !== null) {
// We have new updates that haven't been processed yet.
// We'll add them to the base queue.
if (baseQueue !== null) {
// Merge the pending queue and the base queue.
const baseFirst = baseQueue.next;
const pendingFirst = pendingQueue.next;
baseQueue.next = pendingFirst;
pendingQueue.next = baseFirst;
}
current.baseQueue = baseQueue = pendingQueue;
queue.pending = null;
}
const baseState = hook.baseState;
if (baseQueue === null) {
{...}
// If there are no pending updates, then the memoized state should be the
// same as the base state. Currently these only diverge in the case of
// useOptimistic, because useOptimistic accepts a new baseState on
// every render.
hook.memoizedState = baseState;
// We don't need to call markWorkInProgressReceivedUpdate because
// baseState is derived from other reactive values.
} 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 state
newState = ((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 as useReducer()

hook: Hook,
current: Hook,
reducer: (S, A) => S,
): [S, Dispatch<A>] {
{...}
const queue = hook.queue;
if (queue === null) {
throw new Error(
'Should have a queue. This is likely a bug in React. Please file an issue.',
);
}
queue.lastRenderedReducer = reducer;
// The last rebase update that is NOT part of the base state.
let baseQueue = hook.baseQueue;
// The last pending update that hasn't been processed yet.
const pendingQueue = queue.pending;
if (pendingQueue !== null) {
// We have new updates that haven't been processed yet.
// We'll add them to the base queue.
if (baseQueue !== null) {
// Merge the pending queue and the base queue.
const baseFirst = baseQueue.next;
const pendingFirst = pendingQueue.next;
baseQueue.next = pendingFirst;
pendingQueue.next = baseFirst;
}
current.baseQueue = baseQueue = pendingQueue;
queue.pending = null;
}
const baseState = hook.baseState;
if (baseQueue === null) {
{...}
// If there are no pending updates, then the memoized state should be the
// same as the base state. Currently these only diverge in the case of
// useOptimistic, because useOptimistic accepts a new baseState on
// every render.
hook.memoizedState = baseState;
// We don't need to call markWorkInProgressReceivedUpdate because
// baseState is derived from other reactive values.
} 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 state
newState = ((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.

  1. An optimistic update has revertLane of a transition lane and lane of SyncLane
  2. 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
  3. 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

  1. there is a global transition object to indicate if we need to schedule updates in lower priorities(transition lanes)
  2. startTransition() is to set this transition object to non-null, so that the setState() calls inside its scope schedules low priority updates.

4.4 <form/> now accepts async action

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) {
{...}
if (formFiber.tag !== HostComponent) {
throw new Error(
"Expected the form instance to be a HostComponent. This " +
"is a bug in React."
);
}
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 to

the 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 which

we trigger the setOptimisticState()

}
);

Internally startTransition is called

}
function startHostTransition(formFiber, pendingState, callback, formData) {
{...}
if (formFiber.tag !== HostComponent) {
throw new Error(
"Expected the form instance to be a HostComponent. This " +
"is a bug in React."
);
}
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 to

the 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 which

we trigger the setOptimisticState()

}
);

Internally startTransition is called

}

Now things become a bit clearer now.

  1. <form/> has an async action, when it is submitted, a state hook is created internally for it and a transition is started.
  2. during the async action, optimistic state updates are scheduled with the revertLane being the transition lane
  3. 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 time

Why it is NOT optimistic? I think it is because the form state is important

and it should be committed right away

}
} catch (error) {
{...}
{
// This is a trick to get the `useTransition` hook to rethrow the error.
// When it unwraps the thenable with the `use` algorithm, the error
// will be thrown.
var rejectedThenable = {
then: function () {},
status: "rejected",
reason: error,
};
dispatchSetState(fiber, queue, rejectedThenable);
}
} finally {
setCurrentUpdatePriority(previousPriority);
ReactCurrentBatchConfig$3.transition = prevTransition;
{
if (prevTransition === null && currentTransition._updatedFibers) {
var updatedFibersCount = currentTransition._updatedFibers.size;
currentTransition._updatedFibers.clear();
if (updatedFibersCount > 10) {
warn(
"Detected a large number of updates inside startTransition. " +
"If this is due to a subscription please re-write it to use React provided hooks. " +
"Otherwise concurrent mode guarantees are off the table."
);
}
}
}
}
}
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 time

Why it is NOT optimistic? I think it is because the form state is important

and it should be committed right away

}
} catch (error) {
{...}
{
// This is a trick to get the `useTransition` hook to rethrow the error.
// When it unwraps the thenable with the `use` algorithm, the error
// will be thrown.
var rejectedThenable = {
then: function () {},
status: "rejected",
reason: error,
};
dispatchSetState(fiber, queue, rejectedThenable);
}
} finally {
setCurrentUpdatePriority(previousPriority);
ReactCurrentBatchConfig$3.transition = prevTransition;
{
if (prevTransition === null && currentTransition._updatedFibers) {
var updatedFibersCount = currentTransition._updatedFibers.size;
currentTransition._updatedFibers.clear();
if (updatedFibersCount > 10) {
warn(
"Detected a large number of updates inside startTransition. " +
"If this is due to a subscription please re-write it to use React provided hooks. " +
"Otherwise concurrent mode guarantees are off the table."
);
}
}
}
}
}

It is a bit complex here, there are two updates dispatched.

  1. 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.

  1. 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.

import { useState, useRef, useOptimistic } from "react";
import { deliverMessage } from "./actions.js";

function Thread({ messages, sendMessage }) {
  const formRef = useRef();
  async function formAction(formData) {
    addOptimisticMessage(formData.get("message"));
    formRef.current.reset();
    await sendMessage(formData);
  }
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (state, newMessage) => [
      ...state.filter(msg => msg.text !== newMessage),
      {
        text: newMessage,
        sending: true
      }
    ]
  );

  return (
    <>
      {optimisticMessages.map((message, index) => (
        <div key={index}>
          {message.text}
          {!!message.sending && <small> (Sending...)</small>}
        </div>
      ))}
      <form action={formAction} ref={formRef}>
        <input type="text" name="message" placeholder="Hello!" />
        <button type="submit">Send</button>
      </form>
    </>
  );
}

export default function App() {
  const [messages, setMessages] = useState([
    { text: "Hello there!", sending: false, key: 1 }
  ]);
  async function sendMessage(formData) {
    const sentMessage = await deliverMessage(formData.get("message"));
    setMessages((messages) => [...messages, { text: sentMessage }]);
  }
  return <Thread messages={messages} sendMessage={sendMessage} />;
}

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!

  1. <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.
  2. 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 without setState().
  3. When an optimistic update is dispatched, its revertLane is set with the transition lane.
  4. 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.
  5. 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!

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

❮ Prev: How does use() work internally in React?

Next: Deeper Dive Chrome Extension - Enhance React.dev