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.
