Realtime Usage Guide¶
This guide explains how to use realtime database subscriptions in the Delta application.
Overview¶
We provide a custom useRealtime hook for subscribing to database changes. The hook is database-agnostic and currently implemented with Supabase Realtime. It supports multiple table subscriptions through a single WebSocket channel for better performance.
The hook handles React Strict Mode, cleanup, and ensures callbacks always use the latest function references. Most importantly, it automatically invalidates React Query cache when database changes occur, ensuring your UI always shows fresh data.
Two Approaches to Realtime Subscriptions¶
Approach 1: Centralized Realtime Listener (Recommended for App-wide Data)¶
For data that affects multiple parts of the application (projects, organizations, user access, etc.), we use a centralized RealtimeListener component that's mounted once at the app root. This approach:
- Reduces subscription duplication across components
- Automatically invalidates React Query cache for affected queries
- Simplifies maintenance by centralizing subscriptions
The centralized listener is located at /components/realtime/RealtimeListener.tsx and handles:
- Projects and Project Relationships
- Organizations
- User Access changes
- Menu Items
- Permission Role Links
- Transactions and Transaction Items
Example of centralized listener:
// In RealtimeListener.tsx
export function RealtimeListener() {
const { user } = useUser();
useRealtime({
enabled: !!user,
tables: [
{
table: DatabaseTable.PROJECTS,
onChange: (payload) => {
clientLogger.debug('Project changed:', { payload });
// React Query cache will be automatically invalidated
},
},
// ... other app-wide subscriptions
],
});
return null;
}
Approach 2: Component-specific Subscriptions (For Complex/Filtered Cases)¶
Use component-specific useRealtime when you need:
- Complex filtering logic that changes based on component state
- Component-specific callbacks beyond cache invalidation
- Dynamic subscriptions based on props
Examples of component-specific subscriptions:
- ProcessingStatusBar - Subscribes to AI processing jobs with complex filtering
- TransactionsList - Subscribes to specific transactions it's displaying
- DataTableProvider - Dynamic subscriptions based on table configuration
// Example: Component with complex filtering
function ProcessingStatusBar({ processableType }) {
const { projectId } = useProject();
useRealtime({
enabled: true,
tables: [
{
table: DatabaseTable.AI_PROCESSING_JOBS,
// Instead of database filter, we filter in onChange for stability
onChange: (payload) => {
const record = payload.new || payload.old;
// Complex filtering logic
if (
record?.processable_type === processableType &&
record?.project_id === projectId
) {
handleUpdate();
}
},
},
],
});
}
Basic Usage¶
import { useRealtime } from '@/hooks/useRealtime';
import { DatabaseTable, DatabaseEvent } from '@/constants/database';
function MyComponent() {
const { user } = useUser();
// Subscribe to multiple tables with one channel
useRealtime({
enabled: !!user,
tables: [
{
table: DatabaseTable.PROJECTS,
onChange: (payload) => {
console.log('Project changed:', payload);
// payload.eventType will be DatabaseEvent.INSERT/UPDATE/DELETE
// payload.new contains the new record (for INSERT/UPDATE)
// payload.old contains the old record (for UPDATE/DELETE)
},
},
{
table: DatabaseTable.PROJECT_RELATIONSHIPS,
event: DatabaseEvent.UPDATE,
onChange: (payload) => {
console.log('Relationship updated:', payload);
},
},
],
});
}
Single Table Subscription¶
For subscribing to just one table, you can still use the same hook:
useRealtime({
tables: [
{
table: DatabaseTable.PROJECTS,
event: DatabaseEvent.INSERT,
onChange: (payload) => {
console.log('New project:', payload.new);
},
},
],
});
Hook Options¶
| Option | Type | Default | Description |
|---|---|---|---|
tables |
TableSubscription[] |
Required | Array of table subscriptions |
enabled |
boolean |
true |
Whether all subscriptions are enabled |
Each TableSubscription in the tables array has these options:
| Option | Type | Default | Description |
|---|---|---|---|
table |
DatabaseTable |
Required | The database table to subscribe to (use enum from @/constants/database) |
event |
DatabaseEvent |
DatabaseEvent.ALL |
The type of database event to listen for (use enum from @/constants/database) |
filter |
string |
undefined |
PostgreSQL filter string (e.g., 'user_id=eq.123') |
onChange |
(payload: RealtimePayload<T>) => void |
undefined |
Callback when a database change occurs. Payload includes eventType, new, old, and table |
Real-World Example: Navigation Menu Updates¶
The NavigationProvider uses useRealtime to efficiently subscribe to all navigation-related tables:
import { DatabaseTable } from '@/constants/database';
export function NavigationProvider({ children }: NavigationProviderProps) {
const { user } = useAuth();
const currentProjectId = useProjectId();
// Fetch menu data function
const fetchMenuData = useCallback(async () => {
// ... fetch menu implementation
}, [user, currentProjectId]);
// Subscribe to all navigation-related tables in one channel
useRealtime({
enabled: !!user && !!currentProjectId,
tables: [
{
table: DatabaseTable.MENU_ITEMS,
onChange: () => {
clientLogger.info('Menu items changed, refreshing navigation...');
fetchMenuData();
},
},
{
table: DatabaseTable.PERMISSION_ROLES,
onChange: () => {
clientLogger.info(
'Role permissions changed, refreshing navigation...'
);
fetchMenuData();
},
},
],
});
}
Benefits of Multiple Table Subscriptions¶
- Single WebSocket Channel: All subscriptions share one channel, reducing overhead
- Easier Management: All related subscriptions are defined in one place
- Better Performance: Fewer connections mean less resource usage
- Cleaner Code: More readable and maintainable than multiple individual hooks
Automatic React Query Cache Invalidation¶
IMPORTANT: The useRealtime hook automatically invalidates React Query cache for the affected tables. This happens regardless of where you call useRealtime from (centralized listener or component-specific).
The hook wraps all onChange callbacks to:
- Execute your custom onChange logic (if provided)
- Automatically call
queryClient.invalidateQueries()for the relevant query keys
This means when a database change occurs:
- Your custom
onChangeruns (if you provided one) - React Query cache is invalidated for that table
- Components using React Query hooks will automatically refetch and re-render with fresh data
Example flow:
- User updates a project name in the database
- Realtime event triggers for
DatabaseTable.PROJECTS useRealtimecalls youronChange(if any)useRealtimeautomatically invalidatesqueryKeys.projects()- All components using
useProjectQuery()oruseProjectsQuery()refetch - UI updates with the new project name
Important Notes¶
⚠️ CRITICAL: Supabase Realtime Filter Limitations¶
Supabase Realtime only supports single filter conditions! This is a critical limitation that affects how you write filters:
- ✅ Supported:
filter: 'user_id=eq.123'(single condition) - ❌ NOT Supported:
filter: 'user_id=eq.123,project_id=eq.456'(multiple conditions) - ❌ NOT Supported:
filter: 'user_id=eq.123 AND project_id=eq.456'(AND conditions)
Workaround for Multiple Conditions¶
When you need to filter by multiple conditions, you must:
- Filter by the most selective single condition
- Manually check additional conditions in the callback
Example from NavigationProvider:
useRealtime({
tables: [
{
table: DatabaseTable.USER_ACCESSES,
// Can only filter by user_id
filter: `user_id=eq.${user?.id}`,
onChange: (payload) => {
// Manually check additional conditions in the callback
const changedRecord = payload.new || payload.old;
if (
changedRecord &&
changedRecord.scope_type === 'project' &&
changedRecord.scope_id === currentProjectId
) {
// Only refresh if it's for the current project
fetchMenuData();
}
},
},
],
});
This limitation is important to understand because:
- You might receive more events than needed (filtered by only one condition)
- You must handle additional filtering in your callback
- Performance implications: callbacks may fire more often than expected
Database-Agnostic Architecture¶
The realtime subscription system follows a database-agnostic pattern:
- Type Safety: Use
DatabaseTableandDatabaseEventenums from@/constants/databaseinstead of string literals - Abstraction Layer: The
realtimeHelperhandles all database-specific implementation details - Consistent Patterns: Follows the same abstraction pattern as
databaseHelperandstorageHelper - Future-Proof: Easy to swap database providers without changing component code
React Strict Mode¶
The hook includes a 100ms delay before establishing subscriptions to avoid issues with React Strict Mode's double-mounting behavior in development.
Callback References¶
The hook automatically handles callback references internally using refs, so you don't need to manually manage refs for your onChange callbacks. The hook ensures that the latest version of your callback is always called.
Database Requirements¶
- Enable Realtime on Tables: Tables must have realtime enabled in the database:
ALTER PUBLICATION supabase_realtime ADD TABLE your_table;
-
Row Level Security (RLS): If RLS is enabled on a table, ensure users have appropriate SELECT permissions to receive realtime events.
-
Performance: Be mindful of enabling realtime on frequently updated tables as it can impact performance.
Debugging¶
The hook logs useful information to help debug issues:
- "Setting up realtime subscriptions for: [table names]" - When subscriptions are initiated
- "Realtime subscription status for [table names]: [status]" - Connection status updates
- "Realtime event for [table]:" - When an event is received (logged in realtimeHelper)
- "Cleaning up realtime subscriptions for: [table names]" - When subscriptions are cleaned up
Best Practices¶
Avoiding Subscription Conflicts¶
When multiple components need realtime updates from the same table, it's better to have a single shared subscription that fetches all the data, rather than multiple filtered subscriptions that can conflict.
Problem Example:
// ❌ Bad: Multiple components with conflicting subscriptions
// In Component A
useRealtime({
tables: [
{
table: DatabaseTable.PROJECTS,
filter: `id=eq.${projectId}`,
onChange: () => {
/* ... */
},
},
],
});
// In Component B
useRealtime({
tables: [
{
table: DatabaseTable.PROJECTS, // Same table, different/no filter
onChange: () => {
/* ... */
},
},
],
});
Solution:
// ✅ Good: Single shared subscription with data distribution
// In a shared hook (e.g., useProjects)
export function useProjects() {
const [projects, setProjects] = useState<Project[]>([]);
useRealtime({
tables: [
{
table: DatabaseTable.PROJECTS,
onChange: async () => {
const allProjects = await fetchAllProjects();
setProjects(allProjects);
},
},
],
});
return { projects };
}
// In Component A
const { projects } = useProjects();
const currentProject = projects.find((p) => p.id === projectId);
// In Component B
const { projects } = useProjects();
// Use all projects as needed
This pattern prevents subscription conflicts where filtered and unfiltered subscriptions to the same table can interfere with each other, causing some components to stop receiving updates.
Troubleshooting Filter Issues¶
If you experience issues with realtime updates not working properly when using filters (especially dynamic filters that change based on state), consider moving the filtering logic to the onChange handler instead of using database filters:
Problem: Dynamic filters causing subscription recreation
// ❌ Problematic: Filter changes cause subscription recreation
const jobIds = jobs.map((j) => j.id); // This array changes as jobs update
useRealtime({
tables: [
{
table: DatabaseTable.JOB_STEPS,
filter: `job_id=in.(${jobIds.join(',')})`, // Filter recreates subscription
onChange: () => {
/* ... */
},
},
],
});
Solution: Client-side filtering in onChange
// ✅ Better: Stable subscription with client-side filtering
const trackedJobIds = useRef(new Set()); // Stable reference
// Add job IDs to tracked set (only add, never remove)
useEffect(() => {
jobs.forEach((job) => trackedJobIds.current.add(job.id));
}, [jobs]);
useRealtime({
tables: [
{
table: DatabaseTable.JOB_STEPS,
// No filter - subscribe to all events
onChange: (payload) => {
const record = payload.new || payload.old;
// Filter on client side
if (record && trackedJobIds.current.has(record.job_id)) {
// Process only events for tracked jobs
handleUpdate();
}
},
},
],
});
Benefits of client-side filtering:
- Stable subscriptions: No recreation when state changes
- No missed events: Events aren't lost during subscription recreation
- Better performance: Avoids WebSocket reconnection overhead
- More flexible: Can apply complex filtering logic not supported by database filters
When to use this pattern:
- When filters depend on frequently changing state
- When experiencing missing realtime updates
- When you need complex filtering logic (multiple conditions, OR operations)
- When tracking a dynamic set of records
Trade-offs:
- Receives more events than needed (filtered client-side)
- Slightly higher bandwidth usage
- More callback invocations (but filtered early)
This approach has proven effective in fixing realtime reliability issues, particularly with AI processing status updates where job IDs change frequently as jobs complete.
Environment Configuration¶
Ensure your .env.local file has the correct Supabase URL:
NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321 # For local development
Note: WebSocket connections work with localhost but may have issues with 127.0.0.1 in some environments.
Available Database Tables and Events¶
Database Tables (from @/constants/database)¶
export enum DatabaseTable {
PROJECTS = 'projects',
USER_ACCESSES = 'user_accesses',
PROJECT_RELATIONSHIPS = 'project_relationships',
PERMISSION_ROLE_LINKS = 'permission_role_links',
MENU_ITEMS = 'menu_items',
// ... other tables
}
Database Events (from @/constants/database)¶
export enum DatabaseEvent {
ALL = '*',
INSERT = 'INSERT',
UPDATE = 'UPDATE',
DELETE = 'DELETE',
}
Always import and use these enums instead of string literals for type safety and consistency.