惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

博客园_首页
博客园 - 【当耐特】
博客园 - 叶小钗
阮一峰的网络日志
阮一峰的网络日志
WordPress大学
WordPress大学
D
Docker
T
The Blog of Author Tim Ferriss
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Microsoft Azure Blog
Microsoft Azure Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
月光博客
月光博客
M
MIT News - Artificial intelligence
H
Hackread – Cybersecurity News, Data Breaches, AI and More
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
云风的 BLOG
云风的 BLOG
F
Fortinet All Blogs
罗磊的独立博客
小众软件
小众软件
A
About on SuperTechFans
MyScale Blog
MyScale Blog
D
DataBreaches.Net
The GitHub Blog
The GitHub Blog
C
Check Point Blog
L
LangChain Blog

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Optimistic UI Patterns with Next.js Server Actions and Su...
Mahdi BEN RHOUMA · 2026-06-12 · via 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