⚡ Building Fast and Fluid Experiences with Optimistic UI in React
When users interact with your UI—clicking buttons, submitting forms, checking off items—they expect it to respond instantly. But when those actions involve network requests, even a small delay can make the UI feel sluggish.
That’s where Optimistic UI comes in.
Instead of waiting for the server to respond before updating the UI, we optimistically assume
it will succeed and update the interface immediately. In this post, you’ll learn how to implement
Optimistic UI with React’s powerful tools like useOptimistic
and useFormStatus
, plus handle
multi-step form transitions—all without hurting user experience.
Optimistic UI is based on a simple assumption:
“Most of the time, user actions will succeed.”
So rather than waiting for confirmation from the server, we immediately update the UI with what we expect to happen. If something goes wrong, we can always roll it back.
Example: If a user checks off a todo, we mark it complete instantly and send a request in the background.
✅ Fast feedback → ⚡ Better UX
You can learn more about this concept at the end of this talk by Kent C. Dodds.
useOptimistic
: Optimism Inside TransitionsReact Suspense and useTransition
are great for deferring UI changes until data is ready. But what
if we want to optimistically change the UI even while it’s still suspended?
That’s what useOptimistic
is for. It works like useState
, but lets you override state
during a transition (such as when submitting a form). This makes it ideal for implementing optimistic UI.
function Todo({ todo }: { todo: TodoItem }) {const [isComplete, setIsComplete] = useOptimistic(todo.isComplete)return (<formaction={async () => {setIsComplete(!isComplete) // Update UI optimisticallyawait updateTodo(todo.id, !isComplete) // Perform server update}}><label><inputtype="checkbox"checked={isComplete}className="todos-checkbox"/>{todo.text}</label></form>)}
Notice how isComplete
updates instantly, even before the server responds. Once the action
completes (success or error), React will re-render using the actual prop value again (todo.isComplete
).
useFormStatus
: Track Submission StateSometimes you just want to let users know the form is submitting, maybe disable the button or
change its label. That’s where useFormStatus
shines.
You can think of the <form>
as a context provider, and useFormStatus
as the consumer.
function SubmitButton() {const formStatus = useFormStatus()return (<button type="submit" disabled={formStatus.pending}>{formStatus.pending ? 'Creating...' : 'Create'}</button>)}
With this, your button knows when the form is submitting and adjusts accordingly.
📚 Learn more about useFormStatus
Let’s say you’re building a page to create new pokemons.
The issue: When the user submits the form, there’s a noticeable delay before the new pokemon appears. This feels slow—even if your API is fast!
Instead, we’ll:
createOptimisticPokemon(formData)
to simulate the new pokemon.Inside your form action:
action={async (formData) => {const optimisticPokemon = createOptimisticPokemon(formData)setOptimisticPokemon(optimisticPokemon)const realPokemon = await updatePokemon(formData)setSelectedPokemon(realPokemon.name)setOptimisticPokemon(null)}}
Here, createOptimisticPokemon(formData)
creates a pokemon object instantly (e.g., with fetchedAt: '...'
).
We display that in the UI while waiting for updatePokemon()
to finish.
You’ll need to lift state up to the parent <App>
component so that CreateForm
and PokemonDetails
can both access and modify the optimistic pokemon:
function App() {const [selectedPokemon, setSelectedPokemon] = useState(null)const [optimisticPokemon, setOptimisticPokemon] = useState(null)return (<><CreateForm setOptimisticPokemon={setOptimisticPokemon} setSelectedPokemon={setSelectedPokemon} /><PokemonDetails pokemon={optimisticPokemon ?? selectedPokemon} /></>)}
This lets you render the optimistic pokemon first, and the actual one later when it arrives.
useOptimistic
What if your form action does multiple steps?
<formaction={async (formData) => {setMessage('Creating pokemon...')const pokemon = await updatePokemon(formData)setMessage('Saving to database...')await savePokemonToDb(pokemon)setMessage('Almost done...')await notifyFleet(pokemon)}}><SubmitButton /></form>
The problem? You can’t update local state like setMessage
inside a transition… unless you
use useOptimistic
.
function CreateForm() {const [message, setMessage] = useOptimistic('Create')return (<formaction={async (formData) => {setMessage('Creating pokemon...')const pokemon = await updatePokemon(formData)setMessage('Saving to DB...')await savePokemonToDb(pokemon)setMessage('Almost done...')await notifyFleet(pokemon)}}><SubmitButton message={message} /></form>)}function SubmitButton({ message }: { message: string }) {const formStatus = useFormStatus()return <button type="submit" disabled={formStatus.pending}>{message}</button>}
Now, your submit button gives step-by-step feedback to the user—exactly what’s happening and when.
Optimistic UI is a powerful tool to enhance user experience and build responsive apps that feel instant.
TL;DR:
useOptimistic
to override UI state during transitions.useFormStatus
to monitor form state and show submission feedback."..."
for fields not yet fetched.Quick Links
Legal Stuff
Social Media