Optimistic UI in Rails with optimism... and Inertia

Cover for Optimistic UI in Rails with optimism... and Inertia

Your user drags a card across a kanban board. They expect instant feedback. Not a loading spinner, brief flicker, or “please wait.” Here’s the thing: optimistic UI is a lie. And the modern web has trained everyone to expect interfaces that lie convincingly. We show the user what we expect to happen before the server confirms it. With Inertia Rails, telling that lie takes remarkably little code because of how Inertia handles state.

In this post, we’ll build an optimistic UI on a tiny kanban board in Rails + Inertia where cards move instantly while the server catches up. Learn the minimal “update first, sync later” pattern, how Inertia reconciles optimistic state automatically on response, and where things can go wrong (especially with browser history).

Book a call

Irina Nazarova CEO at Evil Martians

Book a call

Why optimistic UI is usually painful

Before we get to the good stuff, let’s acknowledge what makes optimistic UI tricky in most setups:

  • The rollback nightmare. The server says: no—now you need to undo the optimistic change and restore the previous state. Hope you cached it somewhere!
  • Cache invalidation chaos. Multiple components showing the same data? They all need to know about your optimistic update, then they all need to reconcile things when the server responds.
  • Race conditions. The user clicks fast and requests finish out of order. Which state wins?
  • Back button betrayal. The user navigates away and back. Are they seeing real data or your optimistic guess?

These problems share a root cause: your client-side state and server state are separate systems that you have to keep in sync manually. Inertia sidesteps this entirely!

How Inertia manages state

Inertia doesn’t invent its own state management. Instead, it plugs into each framework’s native reactivity system. In React, page data lives in a Context provider that usePage() exposes. Vue wraps everything in a reactive() object. Svelte uses a writable store. No custom abstractions, rather, each adapter speaks its framework’s language.

This means your components automatically re-render when page data changes: no manual subscriptions, fetching, or cache invalidation. The server sends props, the reactive layer updates, and your UI reflects the new state.

Here’s where it gets interesting: since Inertia controls this reactive state, we can update it before a request completes: modify the props, the component re-renders immediately, and the user sees the result without waiting. That’s optimistic UI, and in Inertia, it’s almost trivial to implement.

Meet Izzy: an optimistic kanban board

To demonstrate this, we built a tiny kanban app: Izzy. (And like any good project planner, Izzy is relentlessly optimistic; she moves cards to “Done” before anyone’s actually checked with the server.)

Izzy lets you create todo cards and drag them between status columns. But before we add optimistic updates, let’s see what happens without them. Here’s Izzy with a simulated slow server (sleep 1 in the controller):

Slow interface without optimistic UI

That one-second pause after every action? Users notice. They double-click. They wonder if the app is broken. Let’s fix that.

Inertia’s local visits

Inertia provides three methods for updating page state without a server roundtrip:

  • router.replace(props) — replaces the entire page props object
  • router.replaceProp(key, value) — updates a single prop (value can be a transformer function)
  • router.push(props) — same as replace, but adds a new browser history entry

These are “local visits”, immediate client-side state updates. Combined with regular server requests, they enable the optimistic pattern: update first, sync later.

The pattern

The approach is straightforward:

  1. User triggers an action (form submit, drag-drop, button click)
  2. Call router.replaceProp to update the UI instantly
  3. Fire the actual request to persist the change
  4. Server response automatically replaces props with authoritative data

That last step is where the magic happens. When the server responds, Inertia overwrites our optimistic state with the real data. If our guess was right, nothing visible changes. If we were wrong (validation failed, permission denied), the UI corrects itself. No stale state or manual reconciliation and the server is always the source of truth.

Optimistic form submission

When a user submits a new todo, we want it to appear instantly. The onBefore callback runs right before the request fires; perfect for our optimistic update:

<Form
  method="post"
  action={todos_path()}
  resetOnSuccess={["text"]}
  onBefore={(event) => {
    const data = event.data as { text: string }
    router.replaceProp("todos", (todos: TodosIndex["todos"]) => [
      ...todos,
      { text: data.text, status: "todo", pending: true },
    ])
  }}
>
  <input ref={inputRef} name="text" placeholder="New todo..." />
  <button type="submit">Add</button>
</Form>

We append the new todo to our local state before the POST request leaves. The card appears immediately. The server persists the record and returns updated props (including the real ID). Inertia swaps in the authoritative data silently.

Optimistic drag and drop

We follow the same principle when moving cards between columns:

const handleDrop = (newStatus: TodoStatus) => {
  const id = draggedId.current
  if (!id) return

  const todo = todos.find((t) => t.id === id)
  if (!todo || todo.status === newStatus) return

  // Update UI instantly
  router.replaceProp("todos", (todos: TodosIndex["todos"]) =>
    todos.map((t) => (t.id === id ? { ...t, status: newStatus } : t)),
  )

  // Tell the server (it'll catch up)
  router.patch(todo_path(id), { status: newStatus }, {
    preserveScroll: true,
  })
}

The card moves to the new column immediately, the PATCH request follows, and the server response reconciles the state. It takes two lines for the optimistic bit—that’s the whole pattern.

What Inertia handles automatically

Remember those pain points we outlined earlier? Here’s how Inertia’s architecture addresses them:

  • Rollback nightmare? Every server response replaces your entire page state. Validation fails? The server sends back the correct state, your optimistic guess disappears, done.
  • Cache invalidation? There’s only one source of truth—the page props. No distributed caches to sync.
  • Race conditions? The last server response wins. Inertia doesn’t try to merge conflicting states.
  • Back button? When users navigate, they get a fresh server render. Stale optimistic state doesn’t persist across page visits.

For explicit error feedback, hook into the error callback:

router.patch(todo_path(id), { status: newStatus }, {
  preserveScroll: true,
  onError: handleError,
})

We’re lying temporarily …but we come clean when caught.

Caveat: browser history within a page

There’s one wrinkle worth mentioning. When you use router.push instead of router.replace, you’re creating browser history entries without a server visit. Here’s what can go wrong:

  1. User moves a card from “To Do” to “Done”
  2. You call router.push with the optimistic state
  3. Request fails—server rejects the move
  4. Server response corrects the UI (card back in “To Do”)
  5. User hits Back button
  6. Browser restores the history entry from step 2—the optimistic state where the card was in “Done”

Now the UI shows stale data that was never actually persisted.

So, for most optimistic updates, stick with router.replace or router.replaceProp since they modify the current history entry rather than creating new ones. Reserve router.push for cases where you genuinely want navigable history, like opening a modal with a shareable URL.

The result

Here’s Izzy with optimistic UI enabled, same sleep 1 on the server:

Fast interface with optimistic UI

Same slow server, completely different experience. The user sees instant feedback while the server catches up in the background.

Wrapping up

Optimistic UI in Inertia boils down to one insight: props and router are unified. Call router.replaceProp before your request, the UI updates immediately, and the server response handles reconciliation automatically.

Check out Izzy on GitHub and give it a spin. She’s optimistic you’ll love it!

Book a call

Irina Nazarova CEO at Evil Martians

Playbook.com, Stackblitz.com, Fountain.com, Monograph.com–we joined the best-in-class startup teams running on Rails to speed up, scale up and win! Solving performance problems, shipping fast while ensuring maintainability of the application and helping with team upskill. We can confidently call ourselves the most skilled team in the world in working with startups on Rails. Curious? Let's talk!