VOOZH about

URL: https://dev.to/mahdi_benrhouma_fe1c6005/optimistic-ui-patterns-with-nextjs-server-actions-and-supabase-realtime-7e0

⇱ Optimistic UI Patterns with Next.js Server Actions and Supabase Realtime - DEV Community


Optimistic UI Patterns with Next.js Server Actions and Supabase Realtime

The difference between an app that feels fast and one that feels slow often isn't the actual latency — it's whether the UI responds before the server does. Optimistic updates show the expected result immediately, then reconcile with the server response. Done right, users barely notice network latency.

Next.js 15 ships useOptimistic as a stable React 19 hook, and it integrates cleanly with Server Actions. Combined with Supabase Realtime for multi-user sync, you can build interfaces that feel instant while staying consistent across clients.

Estimated read time: 13 minutes

Prerequisites

  • Next.js 15 (React 19) for stable useOptimistic
  • Supabase project with Realtime enabled
  • Familiarity with Server Actions and useTransition
  • TypeScript recommended

How useOptimistic Works

useOptimistic takes two arguments: the current real state, and an update function that produces the optimistic state. It returns the optimistic state (shown to the user) and a function to trigger an optimistic update.

const [optimisticState, addOptimistic] = useOptimistic(
 realState,
 (currentState, optimisticValue) => {
 // Return the new optimistic state
 return [...currentState, optimisticValue]
 }
)

While a Server Action is in flight:

  • optimisticState shows the optimistic value
  • When the action completes, React reverts to realState (which should now reflect the server's response via revalidatePath or revalidateTag)
  • If the action throws, React reverts to the previous realState automatically

Pattern 1: Optimistic List Item Addition

The most common use case — adding an item to a list without waiting for the server.

// app/todos/TodoList.tsx
'use client'

import { useOptimistic, useTransition } from 'react'
import { addTodo } from './actions'

interface Todo {
 id: string
 text: string
 completed: boolean
 pending?: boolean // flag for optimistic items
}

export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
 const [isPending, startTransition] = useTransition()
 const [optimisticTodos, addOptimisticTodo] = useOptimistic(
 initialTodos,
 (state: Todo[], newTodo: Todo) => [...state, newTodo]
 )

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

 const optimisticTodo: Todo = {
 id: `temp-${Date.now()}`, // temporary ID
 text,
 completed: false,
 pending: true,
 }

 startTransition(async () => {
 addOptimisticTodo(optimisticTodo)
 await addTodo(text)
 })
 }

 return (
 <div>
 <form action={handleSubmit}>
 <input name="text" placeholder="Add todo..." required />
 <button type="submit" disabled={isPending}>Add</button>
 </form>

 <ul>
 {optimisticTodos.map((todo) => (
 <li
 key={todo.id}
 style={{ opacity: todo.pending ? 0.6 : 1 }}
 >
 {todo.text}
 {todo.pending && <span> (saving...)</span>}
 </li>
 ))}
 </ul>
 </div>
 )
}
// app/todos/actions.ts
'use server'

import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'

export async function addTodo(text: string) {
 const supabase = await createClient()
 const { data: { user } } = await supabase.auth.getUser()
 if (!user) throw new Error('Unauthorized')

 const { error } = await supabase
 .from('todos')
 .insert({ text, user_id: user.id, completed: false })

 if (error) throw error

 revalidatePath('/todos')
}

When revalidatePath runs, Next.js re-fetches the Server Component data. The real todo (with a real ID from the database) replaces the optimistic one.


Pattern 2: Optimistic Toggle (Like / Complete)

Toggles are the simplest optimistic pattern — the new state is the inverse of the current state.

// app/todos/TodoItem.tsx
'use client'

import { useOptimistic, useTransition } from 'react'
import { toggleTodo } from './actions'

interface Todo {
 id: string
 text: string
 completed: boolean
}

export function TodoItem({ todo }: { todo: Todo }) {
 const [, startTransition] = useTransition()
 const [optimisticCompleted, setOptimisticCompleted] = useOptimistic(
 todo.completed,
 (_, newValue: boolean) => newValue
 )

 function handleToggle() {
 startTransition(async () => {
 setOptimisticCompleted(!optimisticCompleted)
 await toggleTodo(todo.id, !todo.completed)
 })
 }

 return (
 <li
 onClick={handleToggle}
 style={{
 textDecoration: optimisticCompleted ? 'line-through' : 'none',
 cursor: 'pointer',
 }}
 >
 {todo.text}
 </li>
 )
}

The toggle feels instant. If the server action fails, optimisticCompleted reverts to todo.completed automatically.


Pattern 3: Optimistic Delete with Error Recovery

Deletes need careful handling — if the delete fails, the item must reappear.

// app/todos/TodoList.tsx
'use client'

import { useOptimistic, useTransition, useState } from 'react'
import { deleteTodo } from './actions'

export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
 const [, startTransition] = useTransition()
 const [error, setError] = useState<string | null>(null)

 const [optimisticTodos, removeOptimisticTodo] = useOptimistic(
 initialTodos,
 (state: Todo[], idToRemove: string) =>
 state.filter((t) => t.id !== idToRemove)
 )

 function handleDelete(id: string) {
 setError(null)
 startTransition(async () => {
 removeOptimisticTodo(id)
 try {
 await deleteTodo(id)
 } catch (err: any) {
 setError(`Failed to delete: ${err.message}`)
 // useOptimistic auto-reverts — the item reappears
 }
 })
 }

 return (
 <div>
 {error && <p style={{ color: 'red' }}>{error}</p>}
 <ul>
 {optimisticTodos.map((todo) => (
 <li key={todo.id}>
 {todo.text}
 <button onClick={() => handleDelete(todo.id)}>Delete</button>
 </li>
 ))}
 </ul>
 </div>
 )
}

The try/catch inside startTransition catches the Server Action error and shows it to the user. useOptimistic reverts the state automatically because the transition completed with the original state.


Pattern 4: Combining Optimistic Updates with Supabase Realtime

In multi-user apps, you need both optimistic updates (for the current user) and Realtime updates (for other users). The challenge is avoiding duplicate updates.

// app/todos/RealtimeTodoList.tsx
'use client'

import { createClient } from '@/lib/supabase/client'
import { useOptimistic, useTransition, useEffect, useState } from 'react'
import { addTodo } from './actions'

export function RealtimeTodoList({ initialTodos }: { initialTodos: Todo[] }) {
 const [todos, setTodos] = useState(initialTodos)
 const [pendingIds] = useState(() => new Set<string>())
 const [, startTransition] = useTransition()

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

 // Subscribe to Realtime changes from OTHER users
 useEffect(() => {
 const supabase = createClient()

 const channel = supabase
 .channel('todos')
 .on(
 'postgres_changes',
 { event: 'INSERT', schema: 'public', table: 'todos' },
 (payload) => {
 const newTodo = payload.new as Todo

 // Skip if this is our own optimistic update
 if (pendingIds.has(newTodo.id)) {
 pendingIds.delete(newTodo.id)
 return
 }

 // Add todo from another user
 setTodos((prev) => {
 if (prev.find((t) => t.id === newTodo.id)) return prev
 return [...prev, newTodo]
 })
 }
 )
 .subscribe()

 return () => { supabase.removeChannel(channel) }
 }, [pendingIds])

 async function handleAdd(formData: FormData) {
 const text = formData.get('text') as string
 const tempId = `temp-${Date.now()}`

 const optimisticTodo: Todo = {
 id: tempId,
 text,
 completed: false,
 pending: true,
 }

 startTransition(async () => {
 addOptimisticTodo(optimisticTodo)
 const realId = await addTodo(text) // returns the real DB id
 if (realId) pendingIds.add(realId) // mark to skip in Realtime handler
 })
 }

 return (
 <form action={handleAdd}>
 <input name="text" />
 <button type="submit">Add</button>
 <ul>
 {optimisticTodos.map((todo) => (
 <li key={todo.id} style={{ opacity: todo.pending ? 0.6 : 1 }}>
 {todo.text}
 </li>
 ))}
 </ul>
 </form>
 )
}

The pendingIds set tracks IDs of items we just inserted. When Realtime broadcasts the insert, we check if it's one of ours and skip it to avoid duplication.

[INTERNAL LINK: nextjs-supabase-realtime-collaboration]


Pattern 5: Optimistic Updates for Forms with Validation

For forms with server-side validation, you want to show the optimistic state but handle validation errors gracefully:

// app/profile/ProfileForm.tsx
'use client'

import { useOptimistic, useTransition } from 'react'
import { updateProfile } from './actions'

interface Profile {
 displayName: string
 bio: string
}

export function ProfileForm({ profile }: { profile: Profile }) {
 const [, startTransition] = useTransition()
 const [optimisticProfile, setOptimisticProfile] = useOptimistic(
 profile,
 (_, newProfile: Profile) => newProfile
 )

 async function handleSubmit(formData: FormData) {
 const newProfile = {
 displayName: formData.get('displayName') as string,
 bio: formData.get('bio') as string,
 }

 startTransition(async () => {
 setOptimisticProfile(newProfile)
 const result = await updateProfile(newProfile)

 if (result?.error) {
 // Server action returned a validation error
 // useOptimistic reverts automatically
 // Show error to user via some state mechanism
 }
 })
 }

 return (
 <form action={handleSubmit}>
 <input name="displayName" defaultValue={optimisticProfile.displayName} />
 <textarea name="bio" defaultValue={optimisticProfile.bio} />
 <button type="submit">Save</button>
 </form>
 )
}

Common Pitfalls

Not wrapping addOptimistic in startTransition. useOptimistic only works inside a React transition. Calling addOptimistic outside startTransition will throw in development and silently fail in production.

Using the temporary ID in subsequent operations. The optimistic item has a fake ID (temp-${Date.now()}). If the user tries to delete or update it before the real ID arrives, the operation will fail. Disable action buttons on items with pending: true.

Forgetting revalidatePath in the Server Action. Without revalidation, the Server Component data doesn't update after the action completes. The optimistic state reverts to the stale real state, making it look like the action failed.

Race conditions with rapid successive actions. If a user clicks "add" multiple times quickly, multiple optimistic updates queue up. Each one reverts independently when its action completes. This is usually fine, but test it explicitly.


Summary and Next Steps

useOptimistic + Server Actions is the cleanest optimistic update pattern in the Next.js ecosystem. The key insight: optimistic state is temporary and automatically reverts — your job is to make the real state catch up via revalidatePath, and to handle errors gracefully so users know when something went wrong.

For multi-user apps, combine optimistic updates with Supabase Realtime and deduplicate events from your own mutations using a pending ID set.

Related reading:

  • [INTERNAL LINK: nextjs-server-actions-supabase-complete-guide]
  • [INTERNAL LINK: nextjs-supabase-realtime-collaboration]
  • [INTERNAL LINK: nextjs-supabase-data-fetching-patterns]

Originally published at https://www.iloveblogs.blog