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
Create the todos table and RLS policies
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.
1-- Create the todos table2create 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);910-- Enable RLS11alter table public.todos enable row level security;1213-- Users can read their own todos14create policy "Users read own todos" on public.todos15 for select to authenticated16 using ((select auth.uid()) = user_id);1718-- Users can insert their own todos19create policy "Users insert own todos" on public.todos20 for insert to authenticated21 with check ((select auth.uid()) = user_id);2223-- Users can update their own todos24create policy "Users update own todos" on public.todos25 for update to authenticated26 using ((select auth.uid()) = user_id);2728-- Users can delete their own todos29create policy "Users delete own todos" on public.todos30 for delete to authenticated31 using ((select auth.uid()) = user_id);3233-- Enable real-time for multi-user sync34alter publication supabase_realtime add table public.todos;Expected result: The todos table exists with RLS enabled and policies for all CRUD operations.
Implement optimistic insert with rollback
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.
1import { useState } from 'react'2import { createClient } from '@supabase/supabase-js'34const supabase = createClient(5 process.env.NEXT_PUBLIC_SUPABASE_URL!,6 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!7)89function useTodos(userId: string) {10 const [todos, setTodos] = useState<Todo[]>([])1112 const addTodo = async (task: string) => {13 // 1. Create optimistic item with temp ID14 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 }2223 // 2. Update UI immediately24 setTodos((prev) => [...prev, optimisticTodo])2526 // 3. Send to Supabase27 const { data, error } = await supabase28 .from('todos')29 .insert({ user_id: userId, task, is_complete: false })30 .select()31 .single()3233 if (error) {34 // 4. Roll back on failure35 setTodos((prev) => prev.filter((t) => t.id !== tempId))36 console.error('Insert failed:', error.message)37 return38 }3940 // 5. Replace temp item with server item (real ID)41 setTodos((prev) =>42 prev.map((t) => (t.id === tempId ? data : t))43 )44 }4546 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.
Implement optimistic update and delete
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.
1const toggleTodo = async (id: string, currentStatus: boolean) => {2 // Save previous state for rollback3 const previousTodos = [...todos]45 // Optimistic update6 setTodos((prev) =>7 prev.map((t) =>8 t.id === id ? { ...t, is_complete: !currentStatus } : t9 )10 )1112 const { error } = await supabase13 .from('todos')14 .update({ is_complete: !currentStatus })15 .eq('id', id)1617 if (error) {18 setTodos(previousTodos) // Roll back19 console.error('Update failed:', error.message)20 }21}2223const deleteTodo = async (id: string) => {24 const previousTodos = [...todos]2526 // Optimistic delete27 setTodos((prev) => prev.filter((t) => t.id !== id))2829 const { error } = await supabase30 .from('todos')31 .delete()32 .eq('id', id)3334 if (error) {35 setTodos(previousTodos) // Roll back36 console.error('Delete failed:', error.message)37 }38}Expected result: Toggling and deleting todos feels instant. Failed operations silently restore the previous state.
Add real-time sync for multi-user consistency
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.
1import { useEffect } from 'react'23function useRealtimeSync(4 userId: string,5 setTodos: React.Dispatch<React.SetStateAction<Todo[]>>6) {7 useEffect(() => {8 const channel = supabase9 .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 insert16 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) : t28 )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()4243 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.
Handle edge cases and error states
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.
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 }1011 setTodos((prev) => [...prev, optimisticTodo])1213 const { data, error } = await supabase14 .from('todos')15 .insert({ user_id: userId, task, is_complete: false })16 .select()17 .single()1819 if (error) {20 setTodos((prev) => prev.filter((t) => t.id !== tempId))21 // Show user-facing feedback22 toast.error(`Could not add todo: ${error.message}`)23 return24 }2526 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
1import { useState, useEffect, useCallback } from 'react'2import { createClient } from '@supabase/supabase-js'34const supabase = createClient(5 process.env.NEXT_PUBLIC_SUPABASE_URL!,6 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!7)89interface Todo {10 id: string11 user_id: string12 task: string13 is_complete: boolean14 created_at: string15}1617export function useOptimisticTodos(userId: string) {18 const [todos, setTodos] = useState<Todo[]>([])1920 // Fetch initial data21 useEffect(() => {22 supabase23 .from('todos')24 .select('*')25 .eq('user_id', userId)26 .order('created_at', { ascending: true })27 .then(({ data }) => { if (data) setTodos(data) })28 }, [userId])2930 // Real-time sync31 useEffect(() => {32 const channel = supabase33 .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])5455 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 supabase63 .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])7273 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])7980 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])8687 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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation