50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
next.jsMay 21, 2026·8 min read

Optimistic Updates with Server Actions and Drizzle: Make Your UI Feel Instant

Stop making users stare at spinners. Here's how we wire optimistic updates with Next.js server actions and Drizzle ORM without losing your mind.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

Optimistic Updates with Server Actions and Drizzle: Make Your UI Feel Instant

There's a moment in every app's life where someone clicks a button, watches a spinner for two seconds, and then sees the exact thing they expected to happen. That two seconds is wasted. The user already knew what was going to happen — your app just made them wait for confirmation they didn't need.

Optimistic updates fix this. You update the UI immediately, fire the server request in the background, and roll back if something goes wrong. Simple in theory, slightly annoying in practice. With Next.js server actions and Drizzle, though, it's actually pretty clean — once you know the pattern.

The Basic Idea (And Why It's Worth the Complexity)

The classic example is a like button. User clicks it, you immediately flip the state to 'liked', show the updated count, then call your server action. If the server returns an error, you flip it back. No spinner. No waiting. Just instant feedback.

React 18 gave us useOptimistic specifically for this. It's a hook that lets you have a temporary 'optimistic' version of your state that lives alongside the real state. When the async operation completes, the optimistic state collapses and the real state takes over. If you never used it before, it feels a bit weird at first — like having two versions of reality simultaneously — but it clicks pretty fast.

The pattern works especially well with server actions because they're async by default and you have a clean place to do the actual mutation. Drizzle fits in as your query layer — clean, type-safe, and not trying to be clever about things it shouldn't be clever about.

Setting Up the Server Action with Drizzle

Let's build a real example: a todo list where marking items complete feels instant. First, the server action:

// app/actions/todos.ts
'use server'

import { db } from '@/lib/db'
import { todos } from '@/lib/schema'
import { eq } from 'drizzle-orm'
import { revalidatePath } from 'next/cache'

export async function toggleTodo(id: string, completed: boolean) {
  try {
    await db
      .update(todos)
      .set({ completed, updatedAt: new Date() })
      .where(eq(todos.id, id))

    revalidatePath('/todos')
    return { success: true }
  } catch (error) {
    console.error('Failed to toggle todo:', error)
    return { success: false, error: 'Database update failed' }
  }
}

export async function addTodo(text: string) {
  try {
    const [newTodo] = await db
      .insert(todos)
      .values({
        id: crypto.randomUUID(),
        text,
        completed: false,
        createdAt: new Date(),
        updatedAt: new Date(),
      })
      .returning()

    revalidatePath('/todos')
    return { success: true, todo: newTodo }
  } catch (error) {
    console.error('Failed to add todo:', error)
    return { success: false, error: 'Failed to create todo' }
  }
}

Notice the server action returns a result object instead of throwing. This matters for optimistic updates — you need to know whether to roll back or not. If you just let errors bubble up, your error boundary catches them and you lose control of the rollback logic.

Wiring Up useOptimistic in the Component

Here's where the magic happens. useOptimistic takes your current state and an updater function. When you call the optimistic updater, React immediately re-renders with the fake state while your actual async operation runs.

// app/todos/todo-list.tsx
'use client'

import { useOptimistic, useTransition } from 'react'
import { toggleTodo } from '@/app/actions/todos'

type Todo = {
  id: string
  text: string
  completed: boolean
}

export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
  const [isPending, startTransition] = useTransition()
  
  const [optimisticTodos, updateOptimisticTodos] = useOptimistic(
    initialTodos,
    (currentTodos: Todo[], update: { id: string; completed: boolean }) => {
      return currentTodos.map((todo) =>
        todo.id === update.id
          ? { ...todo, completed: update.completed }
          : todo
      )
    }
  )

  async function handleToggle(todo: Todo) {
    const newCompleted = !todo.completed

    startTransition(async () => {
      // Update UI immediately
      updateOptimisticTodos({ id: todo.id, completed: newCompleted })

      // Fire the server action
      const result = await toggleTodo(todo.id, newCompleted)

      if (!result.success) {
        // useOptimistic automatically reverts when the transition ends
        // but we can also show an error toast here
        console.error('Toggle failed, UI will revert')
      }
    })
  }

  return (
    <ul className="space-y-2">
      {optimisticTodos.map((todo) => (
        <li
          key={todo.id}
          className="flex items-center gap-3 p-3 rounded-lg border"
        >
          <button
            onClick={() => handleToggle(todo)}
            className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
              todo.completed
                ? 'bg-green-500 border-green-500'
                : 'border-gray-300'
            }`}
          >
            {todo.completed && <span className="text-white text-xs">✓</span>}
          </button>
          <span
            className={`flex-1 ${
              todo.completed ? 'line-through text-gray-400' : ''
            }`}
          >
            {todo.text}
          </span>
        </li>
      ))}
    </ul>
  )
}

The key thing to understand: useOptimistic only shows the optimistic state while you're inside a transition. Once the transition completes — success or failure — it falls back to the real state (whatever the server returned, or the original state if something went wrong). This means if your server action fails, the UI automatically reverts. You get rollback for free.

Adding Items Optimistically (The Trickier Case)

Toggling is easy because the item already exists. Adding new items is trickier because you need a temporary ID before the database generates a real one. Here's how we handle that:

// app/todos/add-todo-form.tsx
'use client'

import { useOptimistic, useTransition, useRef } from 'react'
import { addTodo } from '@/app/actions/todos'

type Todo = {
  id: string
  text: string
  completed: boolean
  pending?: boolean  // Track optimistic items
}

export function AddTodoForm({
  todos,
  onTodosChange,
}: {
  todos: Todo[]
  onTodosChange: (todos: Todo[]) => void
}) {
  const formRef = useRef<HTMLFormElement>(null)
  const [isPending, startTransition] = useTransition()

  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (currentTodos: Todo[], newTodo: Todo) => [...currentTodos, newTodo]
  )

  async function handleSubmit(formData: FormData) {
    const text = formData.get('text') as string
    if (!text.trim()) return

    const tempId = `temp-${crypto.randomUUID()}`
    const optimisticTodo: Todo = {
      id: tempId,
      text: text.trim(),
      completed: false,
      pending: true,
    }

    formRef.current?.reset()

    startTransition(async () => {
      addOptimisticTodo(optimisticTodo)

      const result = await addTodo(text.trim())

      if (!result.success) {
        // Optimistic state reverts automatically
        // Show error to user
        alert('Failed to add todo. Please try again.')
      }
      // On success, revalidatePath in the server action
      // will refresh the real todo list from DB
    })
  }

  return (
    <div>
      <form ref={formRef} action={handleSubmit} className="flex gap-2 mb-4">
        <input
          name="text"
          type="text"
          placeholder="Add a todo..."
          className="flex-1 px-3 py-2 border rounded-lg"
          required
        />
        <button
          type="submit"
          disabled={isPending}
          className="px-4 py-2 bg-blue-500 text-white rounded-lg disabled:opacity-50"
        >
          Add
        </button>
      </form>

      <ul className="space-y-2">
        {optimisticTodos.map((todo) => (
          <li
            key={todo.id}
            className={`p-3 rounded-lg border ${
              todo.pending ? 'opacity-60 border-dashed' : ''
            }`}
          >
            {todo.text}
            {todo.pending && (
              <span className="ml-2 text-xs text-gray-400">saving...</span>
            )}
          </li>
        ))}
      </ul>
    </div>
  )
}

The pending flag on optimistic items is a nice touch — you can style them differently (slightly transparent, dashed border, 'saving...' label) so users have a visual cue that it's not confirmed yet. Not required, but it helps with longer operations or slow connections.

The Gotchas We Hit Building This

We didn't get this right first try. A few things bit us:

  • useOptimistic must be called inside a Client Component — obvious, but easy to forget when you're deep in a server component tree
  • The optimistic updater function runs synchronously and must return a new state — don't do async stuff in there
  • If you forget to wrap your server action call in startTransition, the optimistic state reverts immediately and you get a flicker
  • revalidatePath is async under the hood — the UI might jump if the revalidation completes before your component expects it. Throttle your expectations
  • Drizzle's .returning() doesn't work with all databases the same way — SQLite vs PostgreSQL behavior differs, so test on your actual database
  • When you have multiple optimistic updates in flight simultaneously, they can step on each other. We handle this by debouncing rapid-fire actions or using a queue
The most common mistake: calling updateOptimisticTodos outside of startTransition. The optimistic state only persists during a transition — no transition, no optimism.

Error Handling That Doesn't Suck

The automatic rollback is great for the happy path, but you need to tell the user something went wrong. We reach for sonner (a toast library) here — it's lightweight, looks good out of the box, and integrates cleanly with server action results:

import { toast } from 'sonner'

async function handleToggle(todo: Todo) {
  const newCompleted = !todo.completed

  startTransition(async () => {
    updateOptimisticTodos({ id: todo.id, completed: newCompleted })

    const result = await toggleTodo(todo.id, newCompleted)

    if (!result.success) {
      // UI already reverted by this point (transition ended)
      toast.error('Failed to update todo. Your changes were not saved.')
    }
  })
}

// Even better — optimistic toast that updates:
async function handleToggleFancy(todo: Todo) {
  const newCompleted = !todo.completed
  const toastId = toast.loading('Saving...')

  startTransition(async () => {
    updateOptimisticTodos({ id: todo.id, completed: newCompleted })

    const result = await toggleTodo(todo.id, newCompleted)

    if (result.success) {
      toast.success('Saved!', { id: toastId })
    } else {
      toast.error('Failed to save', { id: toastId })
    }
  })
}

The second pattern — a loading toast that resolves to success or error — feels polished without being intrusive. For most mutations, though, we skip the success toast entirely. If the UI already shows the change happened, you don't need to tell the user it happened. Save the toasts for errors.

When Not to Use Optimistic Updates

Not every action deserves optimistic treatment. Doing it everywhere adds complexity and can actually make things feel worse if done wrong.

  • Payment processing — never optimistically confirm a payment. The user needs to know it actually went through
  • Destructive actions — deleting data optimistically makes users nervous. A brief loading state is appropriate here
  • Operations with complex server-side validation — if your server frequently rejects inputs, the rollback happens too often and feels broken
  • Operations where the result isn't predictable — if the server might return something different than what you assumed (e.g., auto-generated slugs, computed fields), optimistic UI can show stale/wrong data
  • Things that take < 200ms anyway — if your DB is fast and local, optimistic updates add code complexity for no visible benefit

The sweet spot: toggles, soft deletes, ordering/sorting, reactions, form submissions where you know the validation will pass. Anything where you can predict the outcome with high confidence.

Putting It Together in a Real App

When we built the dashboard template on peal.dev, we used this exact pattern for things like toggling notification preferences, reordering sidebar items, and marking tasks done. The server actions all return typed result objects, Drizzle handles the mutations, and useOptimistic keeps the UI snappy. The pattern scales cleanly — once you've written one optimistic toggle, the next five take five minutes each.

One thing worth calling out: you'll want to colocate your optimistic logic close to the component that owns the UI, not in some global state manager. The more global you make it, the harder it is to reason about what state you're reverting and when. Keep optimistic updates local — they're a UI concern, not a data concern.

Optimistic updates are a UX feature, not an architecture. Treat them like one. Keep them close to the button that triggers them, handle errors clearly, and don't try to make them the source of truth.

The full pattern: Client Component with useOptimistic + useTransition → immediate UI update → server action call → Drizzle mutation → revalidatePath → real state sync. If the server action fails, return a result object with success: false, the optimistic state rolls back automatically, and you show a toast. That's it. No Redux, no complex state machines, no external libraries beyond what you already have.

Start with the boring cases — toggles and simple mutations. Get comfortable with the rollback behavior by intentionally breaking your server action in dev and watching the UI revert. Once you trust the mechanism, you'll reach for it everywhere it belongs.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders