⚡ 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 starships.
The issue: When the user submits the form, there’s a noticeable delay before the new ship appears. This feels slow—even if your API is fast!
Instead, we’ll:
createOptimisticShip(formData)
to simulate the new ship.Inside your form action:
action={async (formData) => {const optimisticShip = createOptimisticShip(formData)setOptimisticShip(optimisticShip)const realShip = await createShip(formData)setSelectedShip(realShip.name)setOptimisticShip(null)}}
Here, createOptimisticShip(formData)
creates a ship object instantly (e.g., with fetchedAt: '...'
). We display that in the UI while waiting for createShip()
to finish.
You’ll need to lift state up to the parent <App>
component so that CreateForm
and ShipDetails
can both access and modify the optimistic ship:
function App() {const [selectedShip, setSelectedShip] = useState(null)const [optimisticShip, setOptimisticShip] = useState(null)return (<><CreateForm setOptimisticShip={setOptimisticShip} setSelectedShip={setSelectedShip} /><ShipDetails ship={optimisticShip ?? selectedShip} /></>)}
This lets you render the optimistic ship first, and the actual one later when it arrives.
useOptimistic
What if your form action does multiple steps?
<formaction={async (formData) => {setMessage('Creating ship...')const ship = await createShip(formData)setMessage('Saving to database...')await saveShipToDb(ship)setMessage('Almost done...')await notifyFleet(ship)}}><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 ship...')const ship = await createShip(formData)setMessage('Saving to DB...')await saveShipToDb(ship)setMessage('Almost done...')await notifyFleet(ship)}}><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.useOptimistic
DocumentationuseFormStatus
DocumentationQuick Links
Legal Stuff
Social Media