# How to Implement Optimistic UI with Supabase

- Tool: Supabase
- Difficulty: Beginner
- Time required: 15-20 min
- Compatibility: Supabase (all plans), @supabase/supabase-js v2+, React 18+
- Last updated: March 2026

## TL;DR

Optimistic UI updates the interface immediately when a user performs an action, before the server confirms the change. With Supabase, you update local state first, then call the database. If the request fails, you roll back to the previous state. Combined with real-time subscriptions, this pattern keeps multi-user apps consistent while feeling instant.

## Building Instant-Feeling Apps with Optimistic UI and Supabase

Users expect apps to feel fast. Waiting 200-500ms for a server round-trip before showing a change creates noticeable lag. Optimistic UI solves this by updating the interface immediately and syncing with the database in the background. This tutorial walks you through implementing the optimistic update pattern with Supabase in a React app, including error rollback and real-time sync for multi-user scenarios.

## Before you start

- A Supabase project with a table (e.g., todos) and RLS policies configured
- A React app with @supabase/supabase-js installed
- Basic understanding of React useState and useEffect hooks
- The target table added to the supabase_realtime publication if using real-time sync

## Step-by-step guide

### 1. Create the todos table and RLS policies

Before implementing the UI pattern, you need a table with proper security. Create a todos table with an owner column linked to the authenticated user. Enable RLS and add policies so users can only manage their own todos. This ensures optimistic inserts, updates, and deletes are validated server-side even though the UI updates first.

```
-- Create the todos table
create table public.todos (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  task text not null,
  is_complete boolean default false,
  created_at timestamptz default now()
);

-- Enable RLS
alter table public.todos enable row level security;

-- Users can read their own todos
create policy "Users read own todos" on public.todos
  for select to authenticated
  using ((select auth.uid()) = user_id);

-- Users can insert their own todos
create policy "Users insert own todos" on public.todos
  for insert to authenticated
  with check ((select auth.uid()) = user_id);

-- Users can update their own todos
create policy "Users update own todos" on public.todos
  for update to authenticated
  using ((select auth.uid()) = user_id);

-- Users can delete their own todos
create policy "Users delete own todos" on public.todos
  for delete to authenticated
  using ((select auth.uid()) = user_id);

-- Enable real-time for multi-user sync
alter publication supabase_realtime add table public.todos;
```

**Expected result:** The todos table exists with RLS enabled and policies for all CRUD operations.

### 2. Implement optimistic insert with rollback

When the user adds a new todo, immediately append it to the local state with a temporary ID. Then send the insert request to Supabase. If the request fails, remove the temporary item and show an error. This gives the user instant feedback while keeping data consistent.

```
import { useState } from 'react'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

function useTodos(userId: string) {
  const [todos, setTodos] = useState<Todo[]>([])

  const addTodo = async (task: string) => {
    // 1. Create optimistic item with temp ID
    const tempId = crypto.randomUUID()
    const optimisticTodo = {
      id: tempId,
      user_id: userId,
      task,
      is_complete: false,
      created_at: new Date().toISOString(),
    }

    // 2. Update UI immediately
    setTodos((prev) => [...prev, optimisticTodo])

    // 3. Send to Supabase
    const { data, error } = await supabase
      .from('todos')
      .insert({ user_id: userId, task, is_complete: false })
      .select()
      .single()

    if (error) {
      // 4. Roll back on failure
      setTodos((prev) => prev.filter((t) => t.id !== tempId))
      console.error('Insert failed:', error.message)
      return
    }

    // 5. Replace temp item with server item (real ID)
    setTodos((prev) =>
      prev.map((t) => (t.id === tempId ? data : t))
    )
  }

  return { todos, setTodos, addTodo }
}
```

**Expected result:** New todos appear in the list instantly. If the insert fails, the item disappears and an error is logged.

### 3. Implement optimistic update and delete

Apply the same pattern to toggle and delete operations. Save the previous state before making changes so you can restore it if the server request fails. For toggles, flip the is_complete value locally first. For deletes, remove the item from the array and restore it on error.

```
const toggleTodo = async (id: string, currentStatus: boolean) => {
  // Save previous state for rollback
  const previousTodos = [...todos]

  // Optimistic update
  setTodos((prev) =>
    prev.map((t) =>
      t.id === id ? { ...t, is_complete: !currentStatus } : t
    )
  )

  const { error } = await supabase
    .from('todos')
    .update({ is_complete: !currentStatus })
    .eq('id', id)

  if (error) {
    setTodos(previousTodos) // Roll back
    console.error('Update failed:', error.message)
  }
}

const deleteTodo = async (id: string) => {
  const previousTodos = [...todos]

  // Optimistic delete
  setTodos((prev) => prev.filter((t) => t.id !== id))

  const { error } = await supabase
    .from('todos')
    .delete()
    .eq('id', id)

  if (error) {
    setTodos(previousTodos) // Roll back
    console.error('Delete failed:', error.message)
  }
}
```

**Expected result:** Toggling and deleting todos feels instant. Failed operations silently restore the previous state.

### 4. Add real-time sync for multi-user consistency

Optimistic updates handle the current user, but other users modifying the same data need real-time subscriptions. Subscribe to postgres_changes on the todos table so that inserts, updates, and deletes from other users are reflected in the UI automatically. Avoid duplicating your own optimistic changes by checking whether the incoming row already exists.

```
import { useEffect } from 'react'

function useRealtimeSync(
  userId: string,
  setTodos: React.Dispatch<React.SetStateAction<Todo[]>>
) {
  useEffect(() => {
    const channel = supabase
      .channel('todos-realtime')
      .on(
        'postgres_changes',
        { event: 'INSERT', schema: 'public', table: 'todos' },
        (payload) => {
          setTodos((prev) => {
            // Avoid duplicating our own optimistic insert
            const exists = prev.some((t) => t.id === payload.new.id)
            return exists ? prev : [...prev, payload.new as Todo]
          })
        }
      )
      .on(
        'postgres_changes',
        { event: 'UPDATE', schema: 'public', table: 'todos' },
        (payload) => {
          setTodos((prev) =>
            prev.map((t) =>
              t.id === payload.new.id ? (payload.new as Todo) : t
            )
          )
        }
      )
      .on(
        'postgres_changes',
        { event: 'DELETE', schema: 'public', table: 'todos' },
        (payload) => {
          setTodos((prev) =>
            prev.filter((t) => t.id !== payload.old.id)
          )
        }
      )
      .subscribe()

    return () => {
      supabase.removeChannel(channel)
    }
  }, [userId, setTodos])
}
```

**Expected result:** Changes from other users appear in real time. Your own optimistic changes are not duplicated.

### 5. Handle edge cases and error states

Production optimistic UI needs to handle network failures, RLS denials, and race conditions. Add a toast or inline message when a rollback occurs so the user knows their action did not persist. For rapid sequential actions (like checking off multiple todos), consider debouncing or queuing updates to avoid out-of-order state.

```
const addTodoWithFeedback = async (task: string) => {
  const tempId = crypto.randomUUID()
  const optimisticTodo = {
    id: tempId,
    user_id: userId,
    task,
    is_complete: false,
    created_at: new Date().toISOString(),
  }

  setTodos((prev) => [...prev, optimisticTodo])

  const { data, error } = await supabase
    .from('todos')
    .insert({ user_id: userId, task, is_complete: false })
    .select()
    .single()

  if (error) {
    setTodos((prev) => prev.filter((t) => t.id !== tempId))
    // Show user-facing feedback
    toast.error(`Could not add todo: ${error.message}`)
    return
  }

  setTodos((prev) =>
    prev.map((t) => (t.id === tempId ? data : t))
  )
}
```

**Expected result:** Failed operations show a clear error message and the UI reverts to the correct state.

## Complete code example

File: `use-optimistic-todos.ts`

```typescript
import { useState, useEffect, useCallback } from 'react'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

interface Todo {
  id: string
  user_id: string
  task: string
  is_complete: boolean
  created_at: string
}

export function useOptimisticTodos(userId: string) {
  const [todos, setTodos] = useState<Todo[]>([])

  // Fetch initial data
  useEffect(() => {
    supabase
      .from('todos')
      .select('*')
      .eq('user_id', userId)
      .order('created_at', { ascending: true })
      .then(({ data }) => { if (data) setTodos(data) })
  }, [userId])

  // Real-time sync
  useEffect(() => {
    const channel = supabase
      .channel('todos-sync')
      .on('postgres_changes',
        { event: '*', schema: 'public', table: 'todos' },
        (payload) => {
          if (payload.eventType === 'INSERT') {
            setTodos((prev) => {
              const exists = prev.some((t) => t.id === payload.new.id)
              return exists ? prev : [...prev, payload.new as Todo]
            })
          } else if (payload.eventType === 'UPDATE') {
            setTodos((prev) =>
              prev.map((t) => t.id === payload.new.id ? payload.new as Todo : t)
            )
          } else if (payload.eventType === 'DELETE') {
            setTodos((prev) => prev.filter((t) => t.id !== payload.old.id))
          }
        }
      )
      .subscribe()
    return () => { supabase.removeChannel(channel) }
  }, [userId])

  const addTodo = useCallback(async (task: string) => {
    const tempId = crypto.randomUUID()
    const optimistic: Todo = {
      id: tempId, user_id: userId, task,
      is_complete: false, created_at: new Date().toISOString(),
    }
    setTodos((prev) => [...prev, optimistic])
    const { data, error } = await supabase
      .from('todos').insert({ user_id: userId, task, is_complete: false })
      .select().single()
    if (error) {
      setTodos((prev) => prev.filter((t) => t.id !== tempId))
      return { error }
    }
    setTodos((prev) => prev.map((t) => t.id === tempId ? data : t))
    return { data }
  }, [userId])

  const toggleTodo = useCallback(async (id: string, current: boolean) => {
    const snapshot = [...todos]
    setTodos((prev) => prev.map((t) => t.id === id ? { ...t, is_complete: !current } : t))
    const { error } = await supabase.from('todos').update({ is_complete: !current }).eq('id', id)
    if (error) setTodos(snapshot)
  }, [todos])

  const deleteTodo = useCallback(async (id: string) => {
    const snapshot = [...todos]
    setTodos((prev) => prev.filter((t) => t.id !== id))
    const { error } = await supabase.from('todos').delete().eq('id', id)
    if (error) setTodos(snapshot)
  }, [todos])

  return { todos, addTodo, toggleTodo, deleteTodo }
}
```

## Common mistakes

- **Not rolling back state when the Supabase request fails** — undefined Fix: Always save a snapshot of the current state before the optimistic update. In the error branch, restore the snapshot so the UI matches the actual database state.
- **Duplicating items when combining optimistic updates with real-time subscriptions** — undefined Fix: Before appending a real-time INSERT payload, check if an item with that ID already exists in local state. If it does, skip the insert or replace the optimistic version.
- **Forgetting to add the table to the supabase_realtime publication** — undefined Fix: Run ALTER PUBLICATION supabase_realtime ADD TABLE your_table in the SQL Editor. Without this, real-time subscriptions receive no events.
- **Using the temporary ID in subsequent operations before the server responds** — undefined Fix: Disable edit and delete actions on items that still have a temporary ID. Only enable full interaction after the server response replaces the temp ID with the real one.

## Best practices

- Always snapshot state before optimistic mutations so you can roll back to the exact previous state on failure
- Use crypto.randomUUID() for temporary IDs to avoid collisions with database-generated UUIDs
- Call .select().single() after .insert() to get the server-generated row back for replacing the optimistic version
- Clean up real-time subscriptions in useEffect cleanup functions to prevent memory leaks
- Show user-facing feedback (toast or inline message) when a rollback occurs so users know their action did not save
- Add RLS policies for every operation (SELECT, INSERT, UPDATE, DELETE) before building the UI layer
- Wrap auth.uid() in a select subquery inside RLS policies for per-statement caching and better query performance

## Frequently asked questions

### What happens if the user closes the browser before the server confirms an optimistic update?

The optimistic change is lost from local state, but whether it persists depends on whether the Supabase request completed. When the user reopens the app, it fetches the latest data from the database, which is always the source of truth.

### Does optimistic UI work with Supabase RLS?

Yes. RLS is enforced server-side regardless of what the client does. If an optimistic insert violates an RLS policy, the request fails and the UI rolls back. The user sees the item disappear with an error message.

### How do I prevent duplicate items when using real-time subscriptions with optimistic inserts?

Before appending a real-time INSERT payload to state, check if an item with that ID already exists. If you used a temporary ID for the optimistic version, the real-time event carries the server-generated ID, so compare by task content or replace the temp item by matching on the temp ID.

### Should I use optimistic UI for every Supabase operation?

Not necessarily. Optimistic UI works best for low-risk, easily reversible actions like toggling a checkbox or adding a list item. For destructive or irreversible operations like payments or account deletion, wait for server confirmation before updating the UI.

### Can I use optimistic UI without real-time subscriptions?

Yes. Optimistic UI and real-time are independent patterns. Optimistic UI alone improves perceived performance for the current user. Add real-time subscriptions only if multiple users can modify the same data simultaneously.

### Can RapidDev help implement optimistic UI patterns in my Supabase app?

Yes. RapidDev can architect and implement optimistic update patterns tailored to your data model, including error handling, real-time sync, and conflict resolution for multi-user scenarios.

---

Source: https://www.rapidevelopers.com/supabase-tutorial/how-to-implement-optimistic-ui-with-supabase
© RapidDev — https://www.rapidevelopers.com/supabase-tutorial/how-to-implement-optimistic-ui-with-supabase
