CarboAny / src /hooks /useRealtimeQuery.ts
Esketch's picture
deploy: [P4] Strangler Pattern API Adapter Release (Orphan Clean Build v2)
daaf9d7
Raw
History Blame Contribute Delete
3.69 kB
// Generic Realtime subscription hook for Supabase tables
// Fetches initial data then listens for INSERT/UPDATE/DELETE events
import { useEffect, useState, useCallback, useRef } from 'react';
import { supabase } from '../lib/supabase';
import type { RealtimeChannel } from '@supabase/supabase-js';
type Row = { [key: string]: unknown };
interface UseRealtimeQueryOptions<T> {
table: string;
/** Supabase filter string, e.g. 'user_id=eq.abc123' */
filter?: string;
/** Column to order by (default: created_at desc) */
orderBy?: string;
ascending?: boolean;
limit?: number;
/** Map a raw DB row to the UI type */
mapRow: (row: Row) => T;
/** Unique key extractor (default: row.id) */
getKey?: (item: T) => string;
enabled?: boolean;
}
interface UseRealtimeQueryResult<T> {
data: T[];
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
export function useRealtimeQuery<T>(
options: UseRealtimeQueryOptions<T>,
): UseRealtimeQueryResult<T> {
const {
table,
filter,
orderBy = 'created_at',
ascending = false,
limit,
mapRow,
getKey = (item: T) => (item as Row).id as string,
enabled = true,
} = options;
const [data, setData] = useState<T[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const channelRef = useRef<RealtimeChannel | null>(null);
const fetchData = useCallback(async () => {
if (!enabled) return;
setLoading(true);
setError(null);
let query = supabase.from(table).select('*').order(orderBy, { ascending });
if (limit) query = query.limit(limit);
// Apply simple eq filters from the filter string
if (filter) {
const parts = filter.split('=eq.');
if (parts.length === 2) {
query = query.eq(parts[0], parts[1]);
}
}
const { data: rows, error: err } = await query;
if (err) {
setError(err.message);
setLoading(false);
return;
}
setData((rows ?? []).map(mapRow));
setLoading(false);
}, [table, filter, orderBy, ascending, limit, mapRow, enabled]);
useEffect(() => {
if (!enabled) return;
fetchData();
// Realtime subscription
const channelName = `realtime-${table}-${filter ?? 'all'}`;
const channel = supabase
.channel(channelName)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table,
...(filter ? { filter } : {}),
},
(payload) => {
const eventType = payload.eventType;
if (eventType === 'INSERT') {
const newItem = mapRow(payload.new as Row);
setData((prev) =>
ascending ? [...prev, newItem] : [newItem, ...prev],
);
} else if (eventType === 'UPDATE') {
const updatedItem = mapRow(payload.new as Row);
const key = getKey(updatedItem);
setData((prev) =>
prev.map((item) => (getKey(item) === key ? updatedItem : item)),
);
} else if (eventType === 'DELETE') {
const oldId = (payload.old as Row).id as string;
if (oldId) {
setData((prev) =>
prev.filter((item) => getKey(item) !== oldId),
);
}
}
},
)
.subscribe();
channelRef.current = channel;
return () => {
if (channelRef.current) {
supabase.removeChannel(channelRef.current);
channelRef.current = null;
}
};
}, [table, filter, enabled, ascending, fetchData, mapRow, getKey]);
return { data, loading, error, refetch: fetchData };
}