use-realtime-channel.ts 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. "use client";
  2. import { useEffect, useRef, useState } from "react";
  3. import type { RealtimeChannel, RealtimePostgresChangesPayload } from "@supabase/supabase-js";
  4. import { getSupabaseBrowserClient } from "@/lib/supabase/client";
  5. import {
  6. type ConnectionStatus,
  7. ReconnectionManager,
  8. cleanupChannel,
  9. } from "@/lib/realtime/subscription-manager";
  10. interface PostgresChangesConfig<T extends Record<string, unknown>> {
  11. event: "INSERT" | "UPDATE" | "DELETE" | "*";
  12. schema: string;
  13. table: string;
  14. filter?: string;
  15. onPayload: (payload: RealtimePostgresChangesPayload<T>) => void;
  16. }
  17. interface UseRealtimeChannelOptions<T extends Record<string, unknown>> {
  18. /** Unique channel name. Changing this unsubscribes from the old channel and subscribes to the new one. */
  19. channelName: string | null;
  20. /** Postgres changes subscription config. */
  21. config: PostgresChangesConfig<T>;
  22. /** Whether the subscription is enabled. Defaults to true. */
  23. enabled?: boolean;
  24. }
  25. interface UseRealtimeChannelReturn {
  26. status: ConnectionStatus;
  27. }
  28. /**
  29. * Generic hook for Supabase Realtime channel management.
  30. * Handles subscribe/unsubscribe lifecycle, connection state tracking,
  31. * and reconnection with exponential backoff.
  32. */
  33. export function useRealtimeChannel<T extends Record<string, unknown>>({
  34. channelName,
  35. config,
  36. enabled = true,
  37. }: UseRealtimeChannelOptions<T>): UseRealtimeChannelReturn {
  38. const [status, setStatus] = useState<ConnectionStatus>("disconnected");
  39. const channelRef = useRef<RealtimeChannel | null>(null);
  40. const reconnectRef = useRef(new ReconnectionManager());
  41. const onPayloadRef = useRef(config.onPayload);
  42. const configRef = useRef(config);
  43. // Sync refs inside an effect to satisfy react-hooks/refs (React 19 strict mode)
  44. useEffect(() => {
  45. onPayloadRef.current = config.onPayload;
  46. configRef.current = config;
  47. });
  48. // Reset status when subscription is disabled
  49. const isActive = !!channelName && enabled;
  50. if (!isActive && status !== "disconnected") {
  51. setStatus("disconnected");
  52. }
  53. useEffect(() => {
  54. if (!channelName || !enabled) {
  55. if (channelRef.current) {
  56. const supabase = getSupabaseBrowserClient();
  57. cleanupChannel(channelRef.current, (ch) => supabase.removeChannel(ch));
  58. channelRef.current = null;
  59. }
  60. return;
  61. }
  62. const reconnect = reconnectRef.current;
  63. // Capture non-null value for use in the subscribe closure
  64. const activeChannelName = channelName;
  65. function subscribe(): void {
  66. const supabase = getSupabaseBrowserClient();
  67. const cfg = configRef.current;
  68. if (channelRef.current) {
  69. cleanupChannel(channelRef.current, (ch) => supabase.removeChannel(ch));
  70. channelRef.current = null;
  71. }
  72. setStatus("connecting");
  73. const channel = supabase
  74. .channel(activeChannelName)
  75. .on<T>(
  76. "postgres_changes",
  77. {
  78. event: cfg.event,
  79. schema: cfg.schema,
  80. table: cfg.table,
  81. ...(cfg.filter ? { filter: cfg.filter } : {}),
  82. },
  83. (payload) => {
  84. onPayloadRef.current(payload);
  85. },
  86. )
  87. .subscribe((subscribedStatus) => {
  88. if (subscribedStatus === "SUBSCRIBED") {
  89. setStatus("connected");
  90. reconnect.reset();
  91. } else if (
  92. subscribedStatus === "CHANNEL_ERROR" ||
  93. subscribedStatus === "TIMED_OUT"
  94. ) {
  95. setStatus("error");
  96. reconnect.schedule(() => subscribe());
  97. } else if (subscribedStatus === "CLOSED") {
  98. setStatus("disconnected");
  99. }
  100. });
  101. channelRef.current = channel;
  102. }
  103. subscribe();
  104. return () => {
  105. reconnect.clear();
  106. if (channelRef.current) {
  107. const supabase = getSupabaseBrowserClient();
  108. cleanupChannel(channelRef.current, (ch) => supabase.removeChannel(ch));
  109. channelRef.current = null;
  110. }
  111. setStatus("disconnected");
  112. };
  113. }, [channelName, enabled]);
  114. return { status };
  115. }