Frontend Realtime Sync
Organization-scoped realtime synchronization pattern that automatically invalidates TanStack Query caches when database changes occur.
Core Concepts
Realtime Table Events System
Organization-scoped notification system that eliminates need for organization_id on junction tables:
- •Backend: Triggers insert events into
realtime_table_eventstable - •Frontend: Subscribes to
realtime_table_eventsfiltered by organization - •Event payload:
{ table_name, event_type, organization_id } - •No hardcoded table list - works for any table with backend trigger
Channel Pattern
- •Separate channel per organization:
org:org_123 - •Subscribes to
realtime_table_eventstable with filter:organization_id=eq.{orgId} - •Automatically resubscribes when user's organization memberships change
Query Invalidation (Granular Record-Level)
- •Backend trigger inserts notification into
realtime_table_eventswithtable_nameandrecord_id - •Frontend receives INSERT event with both
table_nameandrecord_idin payload - •Invalidation logic:
- •
.list()queries: ALWAYS invalidate on any table change - •
.record(id)queries: ONLY invalidate whenrecord_idmatches
- •
- •TanStack Query automatically re-fetches affected queries
- •UI updates in real-time across all users in the organization
Performance benefit: With 500 concurrent users updating different records, only affected queries refetch instead of all table queries.
Architecture
Database Change (Postgres)
|
v
Trigger: get_organization_id_for_change()
(Traces change to affected organization + extract record ID)
|
v
INSERT into realtime_table_events
(table_name, event_type, organization_id, record_id)
|
v
Supabase Realtime Publication
|
v
Organization Channel (org:org_123)
|
v
useSupabaseRealtimeSync Hook
|
v
Extract table_name and record_id from payload
|
v
Query Invalidation (granular predicate):
- .list() queries: Always invalidate
- .record(id) queries: Only if record_id matches
|
v
UI Re-fetch & Update (only affected queries)
Adding New Tables to Realtime
Frontend: No changes needed! Just ensure QueryKeys use .list() for list queries or .record(id) for single-record queries.
Backend only (in migration):
- •
Create your table (no organization_id required for junction tables)
sql-- Example: Junction table without organization_id CREATE TABLE my_junction_table ( parent_id uuid REFERENCES parent_table(id), child_id uuid REFERENCES child_table(id), PRIMARY KEY (parent_id, child_id) );
- •
Update get_organization_id_for_change() function:
sqlCREATE OR REPLACE FUNCTION public.get_organization_id_for_change() RETURNS TEXT AS $$ DECLARE org_id TEXT; record_data JSONB; BEGIN IF TG_OP = 'DELETE' THEN record_data := to_jsonb(OLD); ELSE record_data := to_jsonb(NEW); END IF; CASE TG_TABLE_NAME -- Add your new table: WHEN 'my_junction_table' THEN SELECT p.organization_id INTO org_id FROM parent_table p WHERE p.id = (record_data->>'parent_id'); -- ... other tables ... END CASE; RETURN org_id; END; $$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; - •
Attach trigger:
sqlDROP TRIGGER IF EXISTS trigger_notify_org_change ON my_junction_table; CREATE TRIGGER trigger_notify_org_change AFTER INSERT OR UPDATE OR DELETE ON my_junction_table FOR EACH ROW EXECUTE FUNCTION notify_organization_of_table_change();
- •
Verify QueryKeys exist (usually already present):
typescript// src/utils/query/queryKeys.ts export const QueryKeys = { my_junction_table: createTableFactory("my_junction_table"), };
That's it! Frontend automatically invalidates queries when it receives table_name: "my_junction_table" in event payload.
Global Tables (No Realtime)
Tables WITHOUT organization_id should use manual invalidation:
- •
organizations- Use manual invalidation in mutation hooks - •
profiles- Use manual invalidation in mutation hooks - •
role_permissions- Use manual invalidation in mutation hooks
Why no realtime for global tables?
- •No way to scope by organization
- •Would broadcast to all users (privacy/performance issue)
- •Manual invalidation provides instant UI feedback for mutations
Provider Setup
// App.tsx
<Provider_Query>
<Provider_ANTD>
<Provider_SupabaseRealtimeSync>
<RouterProvider router={router} />
</Provider_SupabaseRealtimeSync>
</Provider_ANTD>
</Provider_Query>
The provider should be:
- •After
QueryClientProvider(needs query client context) - •Inside authentication providers (needs user context)
- •Before router/app components
Realtime Flow for Org-Scoped Tables
- •User A creates a project in organization
org_123 - •Postgres INSERT trigger fires on
projectstable - •Trigger calls
get_organization_id_for_change()→ returnsorg_123 - •Trigger inserts into
realtime_table_events:{organization_id: 'org_123', table_name: 'projects', event_type: 'INSERT'} - •Supabase Realtime broadcasts INSERT event to channel
org:org_123 - •All users subscribed to
org:org_123receive notification event - •Frontend extracts
table_name: 'projects'from payload - •
handleTableChangeinvalidates all queries with'projects'in query keys - •TanStack Query re-fetches affected queries
- •UI updates automatically for all users in the organization
Key Advantage: Works even for junction tables without organization_id column, as backend traces relationship to affected organization.