Skip to main content
RapidDev - Software Development Agency
supabase-tutorial

How to Implement Optimistic UI with Supabase

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.

What you'll learn

  • How to update the UI before the server responds for instant feedback
  • How to roll back state when a Supabase request fails
  • How to combine optimistic updates with real-time subscriptions
  • How to handle concurrent edits from multiple users
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner10 min read15-20 minSupabase (all plans), @supabase/supabase-js v2+, React 18+March 2026RapidDev Engineering Team
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.

Prerequisites

  • 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.

typescript
1-- Create the todos table
2create table public.todos (
3 id uuid primary key default gen_random_uuid(),
4 user_id uuid references auth.users(id) on delete cascade not null,
5 task text not null,
6 is_complete boolean default false,
7 created_at timestamptz default now()
8);
9
10-- Enable RLS
11alter table public.todos enable row level security;
12
13-- Users can read their own todos
14create policy "Users read own todos" on public.todos
15 for select to authenticated
16 using ((select auth.uid()) = user_id);
17
18-- Users can insert their own todos
19create policy "Users insert own todos" on public.todos
20 for insert to authenticated
21 with check ((select auth.uid()) = user_id);
22
23-- Users can update their own todos
24create policy "Users update own todos" on public.todos
25 for update to authenticated
26 using ((select auth.uid()) = user_id);
27
28-- Users can delete their own todos
29create policy "Users delete own todos" on public.todos
30 for delete to authenticated
31 using ((select auth.uid()) = user_id);
32
33-- Enable real-time for multi-user sync
34alter 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.

typescript
1import { useState } from 'react'
2import { createClient } from '@supabase/supabase-js'
3
4const supabase = createClient(
5 process.env.NEXT_PUBLIC_SUPABASE_URL!,
6 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
7)
8
9function useTodos(userId: string) {
10 const [todos, setTodos] = useState<Todo[]>([])
11
12 const addTodo = async (task: string) => {
13 // 1. Create optimistic item with temp ID
14 const tempId = crypto.randomUUID()
15 const optimisticTodo = {
16 id: tempId,
17 user_id: userId,
18 task,
19 is_complete: false,
20 created_at: new Date().toISOString(),
21 }
22
23 // 2. Update UI immediately
24 setTodos((prev) => [...prev, optimisticTodo])
25
26 // 3. Send to Supabase
27 const { data, error } = await supabase
28 .from('todos')
29 .insert({ user_id: userId, task, is_complete: false })
30 .select()
31 .single()
32
33 if (error) {
34 // 4. Roll back on failure
35 setTodos((prev) => prev.filter((t) => t.id !== tempId))
36 console.error('Insert failed:', error.message)
37 return
38 }
39
40 // 5. Replace temp item with server item (real ID)
41 setTodos((prev) =>
42 prev.map((t) => (t.id === tempId ? data : t))
43 )
44 }
45
46 return { todos, setTodos, addTodo }
47}

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.

typescript
1const toggleTodo = async (id: string, currentStatus: boolean) => {
2 // Save previous state for rollback
3 const previousTodos = [...todos]
4
5 // Optimistic update
6 setTodos((prev) =>
7 prev.map((t) =>
8 t.id === id ? { ...t, is_complete: !currentStatus } : t
9 )
10 )
11
12 const { error } = await supabase
13 .from('todos')
14 .update({ is_complete: !currentStatus })
15 .eq('id', id)
16
17 if (error) {
18 setTodos(previousTodos) // Roll back
19 console.error('Update failed:', error.message)
20 }
21}
22
23const deleteTodo = async (id: string) => {
24 const previousTodos = [...todos]
25
26 // Optimistic delete
27 setTodos((prev) => prev.filter((t) => t.id !== id))
28
29 const { error } = await supabase
30 .from('todos')
31 .delete()
32 .eq('id', id)
33
34 if (error) {
35 setTodos(previousTodos) // Roll back
36 console.error('Delete failed:', error.message)
37 }
38}

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.

typescript
1import { useEffect } from 'react'
2
3function useRealtimeSync(
4 userId: string,
5 setTodos: React.Dispatch<React.SetStateAction<Todo[]>>
6) {
7 useEffect(() => {
8 const channel = supabase
9 .channel('todos-realtime')
10 .on(
11 'postgres_changes',
12 { event: 'INSERT', schema: 'public', table: 'todos' },
13 (payload) => {
14 setTodos((prev) => {
15 // Avoid duplicating our own optimistic insert
16 const exists = prev.some((t) => t.id === payload.new.id)
17 return exists ? prev : [...prev, payload.new as Todo]
18 })
19 }
20 )
21 .on(
22 'postgres_changes',
23 { event: 'UPDATE', schema: 'public', table: 'todos' },
24 (payload) => {
25 setTodos((prev) =>
26 prev.map((t) =>
27 t.id === payload.new.id ? (payload.new as Todo) : t
28 )
29 )
30 }
31 )
32 .on(
33 'postgres_changes',
34 { event: 'DELETE', schema: 'public', table: 'todos' },
35 (payload) => {
36 setTodos((prev) =>
37 prev.filter((t) => t.id !== payload.old.id)
38 )
39 }
40 )
41 .subscribe()
42
43 return () => {
44 supabase.removeChannel(channel)
45 }
46 }, [userId, setTodos])
47}

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.

typescript
1const addTodoWithFeedback = async (task: string) => {
2 const tempId = crypto.randomUUID()
3 const optimisticTodo = {
4 id: tempId,
5 user_id: userId,
6 task,
7 is_complete: false,
8 created_at: new Date().toISOString(),
9 }
10
11 setTodos((prev) => [...prev, optimisticTodo])
12
13 const { data, error } = await supabase
14 .from('todos')
15 .insert({ user_id: userId, task, is_complete: false })
16 .select()
17 .single()
18
19 if (error) {
20 setTodos((prev) => prev.filter((t) => t.id !== tempId))
21 // Show user-facing feedback
22 toast.error(`Could not add todo: ${error.message}`)
23 return
24 }
25
26 setTodos((prev) =>
27 prev.map((t) => (t.id === tempId ? data : t))
28 )
29}

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

Complete working example

use-optimistic-todos.ts
1import { useState, useEffect, useCallback } from 'react'
2import { createClient } from '@supabase/supabase-js'
3
4const supabase = createClient(
5 process.env.NEXT_PUBLIC_SUPABASE_URL!,
6 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
7)
8
9interface Todo {
10 id: string
11 user_id: string
12 task: string
13 is_complete: boolean
14 created_at: string
15}
16
17export function useOptimisticTodos(userId: string) {
18 const [todos, setTodos] = useState<Todo[]>([])
19
20 // Fetch initial data
21 useEffect(() => {
22 supabase
23 .from('todos')
24 .select('*')
25 .eq('user_id', userId)
26 .order('created_at', { ascending: true })
27 .then(({ data }) => { if (data) setTodos(data) })
28 }, [userId])
29
30 // Real-time sync
31 useEffect(() => {
32 const channel = supabase
33 .channel('todos-sync')
34 .on('postgres_changes',
35 { event: '*', schema: 'public', table: 'todos' },
36 (payload) => {
37 if (payload.eventType === 'INSERT') {
38 setTodos((prev) => {
39 const exists = prev.some((t) => t.id === payload.new.id)
40 return exists ? prev : [...prev, payload.new as Todo]
41 })
42 } else if (payload.eventType === 'UPDATE') {
43 setTodos((prev) =>
44 prev.map((t) => t.id === payload.new.id ? payload.new as Todo : t)
45 )
46 } else if (payload.eventType === 'DELETE') {
47 setTodos((prev) => prev.filter((t) => t.id !== payload.old.id))
48 }
49 }
50 )
51 .subscribe()
52 return () => { supabase.removeChannel(channel) }
53 }, [userId])
54
55 const addTodo = useCallback(async (task: string) => {
56 const tempId = crypto.randomUUID()
57 const optimistic: Todo = {
58 id: tempId, user_id: userId, task,
59 is_complete: false, created_at: new Date().toISOString(),
60 }
61 setTodos((prev) => [...prev, optimistic])
62 const { data, error } = await supabase
63 .from('todos').insert({ user_id: userId, task, is_complete: false })
64 .select().single()
65 if (error) {
66 setTodos((prev) => prev.filter((t) => t.id !== tempId))
67 return { error }
68 }
69 setTodos((prev) => prev.map((t) => t.id === tempId ? data : t))
70 return { data }
71 }, [userId])
72
73 const toggleTodo = useCallback(async (id: string, current: boolean) => {
74 const snapshot = [...todos]
75 setTodos((prev) => prev.map((t) => t.id === id ? { ...t, is_complete: !current } : t))
76 const { error } = await supabase.from('todos').update({ is_complete: !current }).eq('id', id)
77 if (error) setTodos(snapshot)
78 }, [todos])
79
80 const deleteTodo = useCallback(async (id: string) => {
81 const snapshot = [...todos]
82 setTodos((prev) => prev.filter((t) => t.id !== id))
83 const { error } = await supabase.from('todos').delete().eq('id', id)
84 if (error) setTodos(snapshot)
85 }, [todos])
86
87 return { todos, addTodo, toggleTodo, deleteTodo }
88}

Common mistakes when implementing Optimistic UI with Supabase

Why it's a problem: Not rolling back state when the Supabase request fails

How to avoid: 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.

Why it's a problem: Duplicating items when combining optimistic updates with real-time subscriptions

How to avoid: 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.

Why it's a problem: Forgetting to add the table to the supabase_realtime publication

How to avoid: Run ALTER PUBLICATION supabase_realtime ADD TABLE your_table in the SQL Editor. Without this, real-time subscriptions receive no events.

Why it's a problem: Using the temporary ID in subsequent operations before the server responds

How to avoid: 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

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I have a Supabase todos table with RLS enabled. Show me how to implement optimistic UI updates in React where the UI updates instantly on add, toggle, and delete, but rolls back if the Supabase request fails. Include real-time subscriptions for multi-user sync.

Supabase Prompt

Help me build a React hook that performs optimistic inserts, updates, and deletes against a Supabase table. The hook should update local state immediately, call Supabase in the background, roll back on error, and subscribe to real-time changes to stay in sync with other users.

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.