Recently, I came across this video by Web Dev Simplified, which introduced me to React’s new useOptimistic hook.
I began to wonder whether this is even needed. React has never been a library to provide more than
bare-bones controls and hooks. The useCallback
, useMemo
, and useState
hooks are most of the
built-in hooks and they provide a solid base upon which you can build pretty much any logic inside
your components or custom hooks.
So since when does React provide more high-level abstractions above hooks? At that point, they become even more opinionated than they already are. Not all use cases are the same - but this is a pretty generic hook, all things considered. Apparently, it is still in the experimental stage.
I decided to experiment with creating this hook on my own, to understand if it was overcoming some internal system limitation. I looked at various examples of existing optimistic hooks on NPM, but I didn’t really find one that falls in line with more modern coding styles, or they were completely outdated and abandoned.
Why create your own?
Ideally, React should generally keep its size down as much as possible, and not increase the bundle with more bloat that won’t be used by everyone. I believe this hook is a very nice idea, but it is not relevant for everyone, and I think if you find it does fit your needs, you don’t have to wait for official support - you can do it (or copy the final code here) in relatively short time, and feature-complete (and added).
In addition, I thought it would make a good thought experiment and some coding practice, it can’t hurt!
Looking at React’s original implementation
I decided to first take a look at React’s official implementation of this. Not the source code, but I did want to get a sense of whether I like the consensus they came up with, or whether I feel it has room for improvement.
Here is a basic usage example, verbatim from React’s docs:
const [optimisticState, addOptimistic] = useOptimistic(
state,
// updateFn
(currentState, optimisticValue) => {
// merge and return new state
// with optimistic value
},
)
Seems nice, but I think we can do better.
Off the top of my head, I don’t see a way to get the status of the optimistic value. Can I know when it’s been finalized without having to maintain a separate state?
Also, there doesn’t seem to be a way to roll back to the original value if an error has occurred. Or at least, the docs don’t yet specify any. We can fix that, and provide a more “holistic” approach, which in my opinion doesn’t leave the scope of this hook. Say I push an optimistic update, and it fails, how can I roll it back?
We’ll try to make sense of where it should go as we go along.
Creating a hook
Let’s start with the signature, and make sure we have our types going for us.
type StateUpdater<T, A = T> = (currentState: T, newState: A) => T
export function useOptimistic<T, A>(
initial: T,
merge?: StateUpdater<T, A>,
): readonly [T, React.Dispatch<React.SetStateAction<T>>] {
// ...
}
We start with the basics. An initial value, of type T
which is generic, and an optional merge
function. If it’s not provided by the user, we’ll just have it replace the old state with the new
when an update comes. The merge
function can have any type in the argument for the final dispatch,
but we assume T
unless specified.
Now we should add the state that maintains the optimistic value.
export function useOptimistic<T>(
initial: T,
merge?: StateUpdater<T, A>,
): readonly [T, React.Dispatch<React.SetStateAction<A>>] {
const [state, setState] = useState(initial)
return [state, setState]
}
We… have a very simple hook working right now. It doesn’t really do anything special at all. Very bland.
Ok so, what’s the most basic requirement? Well, for starters, it should let us merge the state if we
provide a merge
function, which we haven’t use yet from our arguments. Then, we should take into
account that updating the parent state (held in initial
) should also update the state itself, so
that it will be up-to-date with the latest server value.
Creating the update functions
We’ll start by adding the useEffect
hook, that will update from the parent initial
prop when it
updates.
useEffect(() => {
setState(initial)
}, [initial])
Now, when the initial
value is updated, the internal state will be kept up-to-date with the
change.
We can also implement the merge
functionality. For that, we need to provide another implementation
of the setState
function, which uses it internally and adds some more logic.
const update = useCallback(
(value: SetStateAction<A>) => {
const newValue = typeof value === 'function' ? (value as (value: T) => T)(state) : value
const merged = merge ? merge(state, newValue as A) : (newValue as T)
setState(merged)
},
[state],
)
return [state, update] // instead of [state, setState]
Okay, it’s a little more to break down here.
First, we have an identical callback to setState
now, except it also runs the merge
function
before sending the final value.
In useState
, in addition to passing a new state value, the setState
function can be used with a
callback function, which takes the current state as a parameter, and should return the new state.
The variable newValue
is populated with the result of that process.
Then we just stack the merge
function on top of that, if it exists.
The usage for this would still be:
const [count, setCount] = useOptimistic(0)
// option 1
setCount(1)
// option 2
setCount((c) => c + 1)
All the changes happen internally. Nice.
That’s pretty much it as it stands right now. React’s implementation is pretty similar to this, with maybe a slight variation on how the change is calculated with the parent’s update, to determine when the value should be brought up to date.
A bit anti-climactic? Well, let’s add a few bells and whistles. And we can now, because we don’t need React to build it for us. We’re strong!
What else can we add? Rolling back on failure
The first idea for improvement I have is to be able to catch an error, and roll back the value if it fails. Let’s begin implementing this.
First, we need to have a value to go back to. So every time the developer updates the optimistic state to a new value, and an operation related to it fails, we want to provide a way to revert to the original value.
For a recap, here’s the entire hook as it now is, before we make changes:
type StateUpdater<T, A = T> = (currentState: T, newState: A) => T
export function useOptimistic<T>(
initial: T,
merge?: StateUpdater<T, A>,
): readonly [T, React.Dispatch<React.SetStateAction<A>>] {
const [state, setState] = useState(initial)
useEffect(() => {
setState(initial)
}, [initial])
const update = useCallback(
(value: SetStateAction<A>) => {
const newValue = typeof value === 'function' ? (value as (value: T) => T)(state) : value
const merged = merge ? merge(state, newValue as A) : (newValue as T)
setState(merged)
},
[state],
)
return [state, update]
}
Now, let’s say the developer comes across a case like this:
function MyApp({ comments }) {
const [oComments, addComment] = useOptimistic(
comments || [],
(existingComments, newComment) => [...existingComments, newComment],
)
const sendComment = async (comment: Comment) => {
addComment(comment)
await sendCommentToApi(comment) // could fail and throw
}
return ...
}
In this case, we are sending some API request to send the comment content to the backend and make sure it’s already there.
But if it fails, we will have to revert it to its original content manually by keeping a separate state for the original value, and set it back if it failed.
Instead, we can make our new hook handle this.
Let’s add another state that maintains the previous value:
const [rollbackState, setRollbackState] = useState<T | null>(null)
And now that we have that, we should set this rollback value once we set a new value through the regular setter:
const update = useCallback(
(value: SetStateAction<A>) => {
const newValue = typeof value === 'function' ? (value as (value: T) => T)(state) : value
const merged = merge ? merge(state, newValue as A) : (newValue as T)
setRollbackState(state)
setState(merged)
},
[state],
)
We take this value, save it, and… Do nothing with it. We should probably write that logic somewhere. How’s this:
const rollback = useCallback(() => {
if (rollbackState == null) {
// you can either return here, or throw an error, and be more strict about false rollbacks:
// throw new Error('There is no state to roll back to')
return
}
setState(rollbackState!)
setRollbackState(null)
}, [rollbackState])
And we can also add a done
callback, which ditches the retained value, just for clean up.
Something like this:
const done = useCallback(() => {
setRollbackState(null)
}, [])
Let’s also return these in our hook:
export function useOptimistic<T, A = T>(
initial: T,
merge?: StateUpdater<T, A>,
): readonly [
T,
React.Dispatch<React.SetStateAction<A>>,
{
readonly rollback: () => void
readonly done: () => void
},
] {
// ...
return [state, update, { rollback, done }]
}
Nice, right? We just revert. Now to actually use it, we can update the component:
const [oComments, addComment, { done, rollback }] = useOptimistic(
// ^^^^^^^^^^^^^^^^^^
comments || [],
(existingComments, newComment) => [...existingComments, newComment],
)
const sendComment = async (comment: Comment) => {
try {
addComment(comment)
await sendCommentToApi(comment) // could fail and throw
} catch (e) {
rollback()
}
}
That’s not too bad. If we fail, we use rollback
and get back our previous value, and we never had
to save it separately, which is good for scaling this to multiple places.
But… if you give it a go, you will notice a problem.
Let’s do a small test:
const fail = async (comment: Comment) => {
try {
addComment(comment)
await new Promise((res) => setTimeout(res, 1000))
throw new Error('Oops!')
done()
} catch {
rollback()
}
}
Make this run somewhere and you will realize the value reverted back to null
instead of the old
value, or simply doesn’t roll back. What gives?
Here is the problem: when we are using rollback
, it uses the state variable, rollbackState
, to
set the value back on the main state. But here is the thing - this variable is a dependency in the
callback hook on rollback
. Take a closer look:
// before:
// const [rollbackState, setRollbackState] = useState<T | null>(null)
//
// after:
const rollbackState = useRef<T | null>(null)
const rollback = useCallback(() => {
if (rollbackState == null) {
return
}
setState(rollbackState!)
setRollbackState(null)
}, [rollbackState])
// ^^^^^^^^^^^^^
So why is that bad? Well, in our fail
test above, we used addComment
which is our update
function with a fancy name. When the API call fails we call rollback
. But rollback
was created
bound to the rollbackState
that was there at the time of its creation. We wait
for a promise to
return, and then use the done
or rollback
functions, that were created before the
rollbackState
was even set away from null
and into a value in the first place. So basically we
are re-using the value from before we put anything in it, which means it’s null
, which is the
value we roll back to when using rollback
.
How do we fix this?
Fixing the stale value
There are probably many possible solutions, but here is my very naive one.
How do I make sure I get the correct reference to the updated value?
In reality, I don’t need rollbackState
to be an actual state. Why can I say that with confidence?
Well, I don’t need to call a re-render when it updates, as it is only there to reference a value in
another function when called later, and it’s not displayed in the UI - meaning it has no need to
call for a render when I update it. Also it happens on literally every update
call as well, so I
am trying to uselessly mark the frame dirty for re-render twice when one time is enough.
With that in mind, I can now safely switch the rollbackState
from a state hook to a useRef
hook.
Why would that work? Because a useRef
reference is always a constant object. But the current
value inside that object can be changed. In effect, when we reference a ref value, we can say we are
sure it is the most recent, as the ref never needs to change and no mismatches can occur.
Here’s how it looks after the fix:
const update = useCallback(
(value: SetStateAction<A>) => {
const newValue = typeof value === 'function' ? (value as (value: T) => T)(state) : value
const merged = merge ? merge(state, newValue as A) : (newValue as T)
rollbackState.current = state
setState(merged)
},
[state],
)
const rollback = useCallback(() => {
if (rollbackState.current == null) {
return
}
setState(rollbackState.current!)
rollbackState.current = null
}, [])
const done = useCallback(() => {
rollbackState.current = null
}, [])
This way, no matter when I call the rollback
or done
functions, they will always try to access
the same parent object. With that object, they can modify the inner property and we don’t need to
worry about the data being stale.
Simplifying usage
You might notice that now you can have a pretty redundant pattern of try {...} catch() {...}
where
you would rollback
and done
at the end of each section. Well that’s no fun, we want less code.
Why not add this to our hook?
const run = useCallback(
async (cb: () => Promise<unknown> | unknown) => {
try {
await cb()
done()
} catch (_) {
rollback()
}
},
[done, rollback],
)
Simple yet elegant. If we export this from the hook:
return [state, update as React.Dispatch<React.SetStateAction<A>>, { done, rollback, run }]
Now it will now let us do:
const sendComment = async (comment: Comment) => {
run(async () => {
addComment(comment)
await sendCommentToApi(comment)
})
}
Much simpler, right?
Saving the promise state
One last thing I want us to add is the ability to know when our state is loading. This way, even
without using useMutation
from
@tanstack/react-query, we can get a loading
state of any promise, while working directly with the useOptimistic
hook instead.
For that we can simply add a state or a ref (decide which you want depending on your wanted DX),
which sets a pending
state to true
and false
when we update the state, and when we call
done/rollback, accordingly.
Here is the full entire hook with all the things we made here:
type StateUpdater<T, A = T> = (currentState: T, newState: A) => T
export function useOptimistic<T, A = T>(
initial: T,
merge?: StateUpdater<T, A>,
): readonly [
T,
React.Dispatch<React.SetStateAction<A>>,
{
readonly done: () => void
readonly rollback: () => void
readonly pending: boolean
readonly run: (promise: () => Promise<unknown> | unknown) => void
},
] {
const [state, setState] = useState(initial)
const rollbackState = useRef<T | null>(null)
const [pending, setPending] = useState(false)
useEffect(() => {
setPending(false)
setState(initial)
}, [initial])
const update = useCallback(
(value: SetStateAction<A>) => {
const newValue = typeof value === 'function' ? (value as (value: T) => T)(state) : value
const merged = merge ? merge(state, newValue as A) : (newValue as T)
rollbackState.current = state
setState(merged)
setPending(true)
},
[state],
)
const rollback = useCallback(() => {
if (rollbackState.current == null) {
// throw new Error('There is no state to roll back to')
return
}
setState(rollbackState.current!)
rollbackState.current = null
setPending(false)
}, [])
const done = useCallback(() => {
rollbackState.current = null
setPending(false)
}, [])
const run = useCallback(
async (cb: () => Promise<unknown> | unknown) => {
try {
await cb()
done()
} catch (_) {
rollback()
}
},
[done, rollback],
)
return [
state,
update as React.Dispatch<React.SetStateAction<A>>,
{ done, rollback, pending, run },
] as const
}
Where to go from here?
There are many ways that you can improve this hook, but I feel like it now reached a fairly usable state which we can all build from in our own special ways.
Until React decides (or decides against) promoting useOptimistic
to non-canary channels, maybe we
can all benefit from a relatively simple hook that does exactly this without having to wait.
I hope you found this article useful or interesting. Feel free to ask questions or suggest improvements in the comments!
About the author
My name is Chen Asraf. I’m a programmer at heart — it's both my job that I love and my favorite hobby. Professionally, I make fully fledged, production-ready web, desktop and apps for start-ups and businesses; or consult, advise and help train teams.
I'm passionate about tech, problem solving and building things that people love. Find me on social media: