Real-time Table Changes in Supabase with React.js/Next.js
Real-time applications still amaze me.
The ability to instantly reflect changes in a database to all connected clients makes things feel sophisticated. Thankfully, Supabase makes this easy to set up.
In this article, I'll show you how to subscribe to real-time changes in a Supabase table using Next.js (assuming you already have a Next.js app and Supabase set up).
Enabling Real-time for Your Table
Before diving into the code, you must enable real-time functionality for your specific table in the Supabase dashboard.
I have forgotten this step a few times and spent too long wondering why my subscriptions weren't working.
- Log in to your Supabase dashboard
- Navigate to your project
- Go to Database -> Tables
- Find the table you want to enable real-time for (in our case, 'todos')
- Click on the three dots next to the table name
- Select "Edit table"
- In the "Enable Realtime" section, turn on the toggle for "Enable Realtime for this table"
- Save your changes
Again, our real-time subscriptions won't work without this step, even if your code is correct.
Subscribing to Real-time Changes
Let's create a component that subscribes to real-time changes in our Supabase 'todos' table.
Create a new file named components/TodoList.js
:
import { useState, useEffect, useCallback } from 'react' import { supabase } from '../lib/supabaseClient' export default function TodoList() { const [todos, setTodos] = useState([]) const fetchTodos = useCallback(async () => { const { data, error } = await supabase .from('todos') .select('*') .order('id', { ascending: true }) if (error) console.error('Error fetching todos:', error) else setTodos(data) }, []) useEffect(() => { // Fetch initial todos fetchTodos() // Set up real-time subscription const channel = supabase .channel('custom-all-channel') .on( 'postgres_changes', { event: '*', schema: 'public', table: 'todos' }, (payload) => { console.log('Change received!', payload) if (payload.eventType === 'INSERT') { setTodos(prevTodos => [...prevTodos, payload.new]) } else if (payload.eventType === 'UPDATE') { setTodos(prevTodos => prevTodos.map(todo => todo.id === payload.new.id ? payload.new : todo )) } else if (payload.eventType === 'DELETE') { setTodos(prevTodos => prevTodos.filter(todo => todo.id !== payload.old.id)) } } ) .subscribe() // Cleanup subscription on component unmount return () => { channel.unsubscribe() } }, [fetchTodos]) return ( <ul> {todos.map(todo => ( <li key={todo.id}> {todo.task} - {todo.is_completed ? 'Completed' : 'Pending'} </li> ))} </ul> ) }
Let's walk through this snippet:
I'm using useCallback
for the fetchTodos
function. This prevents unnecessary re-renders and ensures the function's reference stability across renders.
In the useEffect
hook, we set up our real-time subscription when the component mounts. We also clean up the subscription when the component unmounts to prevent memory leaks.
We're using supabase.channel()
to create a new real-time channel. This is more efficient than the older supabase.from('todos').on()
method, allowing for more granular control and better performance.
We're listening for all events ('*') on the 'todos' table. You can optimize this by specifying only the needed events (e.g., 'INSERT', 'UPDATE', 'DELETE').
When a change is received, we update the state directly in the subscription callback. This is more efficient than calling a separate function, as it reduces the number of renders.