React Integration

Use CloudSignal MQTT client in React applications

React Integration

Integrate CloudSignal into your React application with hooks and context.

Installation

npm install @cloudsignal/mqtt-client

Quick Start Hook

Create a reusable hook that handles connection, React StrictMode, and cleanup:

// hooks/useCloudSignal.ts
import { useEffect, useRef, useState, useCallback } from 'react';
import CloudSignal from '@cloudsignal/mqtt-client';

interface Message {
  topic: string;
  payload: unknown;
  receivedAt: number;
}

export function useCloudSignal(options = {}) {
  const { 
    debug = false, 
    tokenServiceUrl = 'https://auth.cloudsignal.app',
    preset = 'desktop' 
  } = options;

  const [isConnected, setIsConnected] = useState(false);
  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  const [messages, setMessages] = useState<Message[]>([]);

  // Refs prevent StrictMode double-connection
  const clientRef = useRef(null);
  const connectingRef = useRef(false);
  const mountedRef = useRef(true);

  const connect = useCallback(async (config) => {
    // Guard against duplicate connections
    if (connectingRef.current || clientRef.current) return;
    connectingRef.current = true;
    setIsConnecting(true);
    setError(null);

    try {
      const client = new CloudSignal({ debug, preset, tokenServiceUrl });

      client.onConnectionStatusChange = (connected) => {
        if (mountedRef.current) setIsConnected(connected);
      };

      client.onAuthError = (err) => {
        if (mountedRef.current) setError(err);
        clientRef.current = null;
      };

      client.onMessage((topic, message) => {
        const payload = (() => {
          try { return JSON.parse(message); } 
          catch { return message; }
        })();
        setMessages(prev => 
          [{ topic, payload, receivedAt: Date.now() }, ...prev].slice(0, 100)
        );
      });

      await client.connectWithToken(config);

      if (!mountedRef.current) {
        client.destroy();
        return;
      }

      clientRef.current = client;
    } catch (err) {
      if (mountedRef.current) setError(err);
    } finally {
      connectingRef.current = false;
      if (mountedRef.current) setIsConnecting(false);
    }
  }, [debug, preset, tokenServiceUrl]);

  const disconnect = useCallback(() => {
    clientRef.current?.destroy();
    clientRef.current = null;
    setIsConnected(false);
  }, []);

  const subscribe = useCallback(async (topic, qos = 1) => {
    await clientRef.current?.subscribe(topic, qos);
  }, []);

  const publish = useCallback((topic, message, options) => {
    const payload = typeof message === 'string' ? message : JSON.stringify(message);
    clientRef.current?.transmit(topic, payload, options);
  }, []);

  useEffect(() => {
    mountedRef.current = true;
    return () => {
      mountedRef.current = false;
      clientRef.current?.destroy();
    };
  }, []);

  return {
    isConnected,
    isConnecting,
    error,
    messages,
    connect,
    disconnect,
    subscribe,
    publish,
    clearMessages: () => setMessages([]),
  };
}

Usage Example

import { useCloudSignal } from './hooks/useCloudSignal';
import { useEffect } from 'react';

export function Dashboard({ session }) {
  const { isConnected, messages, connect, subscribe, publish, error } = useCloudSignal({
    debug: process.env.NODE_ENV === 'development',
  });

  // Connect on mount
  useEffect(() => {
    connect({
      host: 'wss://connect.cloudsignal.app:18885/',
      organizationId: 'your-org-uuid',
      externalToken: session.access_token,
    });
  }, [session.access_token]);

  // Subscribe when connected
  useEffect(() => {
    if (isConnected) {
      subscribe('sensors/+/data');
    }
  }, [isConnected]);

  return (
    <div>
      <p>Status: {isConnected ? '🟢 Connected' : '🔴 Disconnected'}</p>
      {error && <p style={{ color: 'red' }}>Error: {error.message}</p>}
      
      <ul>
        {messages.map((msg, i) => (
          <li key={i}>{msg.topic}: {JSON.stringify(msg.payload)}</li>
        ))}
      </ul>
      
      <button onClick={() => publish('commands/led', { action: 'toggle' })}>
        Toggle LED
      </button>
    </div>
  );
}

Context Provider

For app-wide MQTT access, create a context provider:

// contexts/CloudSignalContext.tsx
import { createContext, useContext, ReactNode } from 'react';
import { useCloudSignal } from '../hooks/useCloudSignal';

const CloudSignalContext = createContext(null);

export function CloudSignalProvider({ children, ...options }) {
  const cloudSignal = useCloudSignal(options);
  return (
    <CloudSignalContext.Provider value={cloudSignal}>
      {children}
    </CloudSignalContext.Provider>
  );
}

export function useCloudSignalContext() {
  const context = useContext(CloudSignalContext);
  if (!context) {
    throw new Error('useCloudSignalContext must be used within CloudSignalProvider');
  }
  return context;
}

Usage:

// App.tsx
import { CloudSignalProvider } from './contexts/CloudSignalContext';

function App() {
  return (
    <CloudSignalProvider 
      tokenServiceUrl="https://auth.cloudsignal.app"
      debug={process.env.NODE_ENV === 'development'}
    >
      <Dashboard />
    </CloudSignalProvider>
  );
}

With Supabase Auth

Complete integration with automatic token refresh:

// hooks/useCloudSignalWithSupabase.ts
import { useEffect, useRef, useState, useCallback } from 'react';
import CloudSignal from '@cloudsignal/mqtt-client';
import { supabase } from '@/lib/supabase';

export function useCloudSignalWithSupabase({ autoConnect = true } = {}) {
  const [isConnected, setIsConnected] = useState(false);
  const [messages, setMessages] = useState([]);
  const clientRef = useRef(null);
  const connectingRef = useRef(false);
  const mountedRef = useRef(true);

  const connect = useCallback(async () => {
    if (connectingRef.current || clientRef.current) return;

    const { data: { session } } = await supabase.auth.getSession();
    if (!session?.access_token) return;

    connectingRef.current = true;

    try {
      const client = new CloudSignal({
        tokenServiceUrl: 'https://auth.cloudsignal.app',
        preset: 'desktop',
      });

      client.onConnectionStatusChange = (connected) => {
        if (mountedRef.current) setIsConnected(connected);
      };

      client.onAuthError = () => {
        clientRef.current = null;
      };

      client.onMessage((topic, message) => {
        try {
          setMessages(prev => [{ topic, payload: JSON.parse(message) }, ...prev].slice(0, 100));
        } catch {
          setMessages(prev => [{ topic, payload: message }, ...prev].slice(0, 100));
        }
      });

      await client.connectWithToken({
        host: 'wss://connect.cloudsignal.app:18885/',
        organizationId: process.env.NEXT_PUBLIC_CLOUDSIGNAL_ORG_ID,
        externalToken: session.access_token,
      });

      if (!mountedRef.current) {
        client.destroy();
        return;
      }

      clientRef.current = client;
    } finally {
      connectingRef.current = false;
    }
  }, []);

  const disconnect = useCallback(() => {
    clientRef.current?.destroy();
    clientRef.current = null;
    setIsConnected(false);
  }, []);

  useEffect(() => {
    mountedRef.current = true;
    if (autoConnect) connect();

    // Handle auth state changes
    const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
      if (event === 'SIGNED_OUT') {
        disconnect();
      } else if (event === 'TOKEN_REFRESHED' && session) {
        // Reconnect with new token
        disconnect();
        setTimeout(connect, 100);
      } else if (event === 'SIGNED_IN' && !clientRef.current) {
        connect();
      }
    });

    return () => {
      mountedRef.current = false;
      subscription.unsubscribe();
      disconnect();
    };
  }, [autoConnect, connect, disconnect]);

  return {
    isConnected,
    messages,
    subscribe: (topic) => clientRef.current?.subscribe(topic),
    publish: (topic, msg) => clientRef.current?.transmit(topic, typeof msg === 'string' ? msg : JSON.stringify(msg)),
    disconnect,
  };
}

Chat Component Example

import { useState } from 'react';
import { useCloudSignalContext } from './contexts/CloudSignalContext';

export function ChatRoom() {
  const { isConnected, messages, subscribe, publish } = useCloudSignalContext();
  const [input, setInput] = useState('');

  // Subscribe on mount
  useEffect(() => {
    if (isConnected) subscribe('chat/room/general');
  }, [isConnected]);

  const sendMessage = () => {
    if (!input.trim()) return;
    publish('chat/room/general', {
      user: 'CurrentUser',
      text: input,
      timestamp: Date.now()
    });
    setInput('');
  };

  const chatMessages = messages.filter(m => m.topic === 'chat/room/general');

  return (
    <div>
      <div>{isConnected ? '🟢 Connected' : '🔴 Disconnected'}</div>
      
      <div>
        {chatMessages.map((msg, i) => (
          <div key={i}>
            <strong>{msg.payload.user}</strong>: {msg.payload.text}
          </div>
        ))}
      </div>
      
      <input
        value={input}
        onChange={e => setInput(e.target.value)}
        onKeyPress={e => e.key === 'Enter' && sendMessage()}
        placeholder="Type a message..."
      />
      <button onClick={sendMessage} disabled={!isConnected}>Send</button>
    </div>
  );
}

Real-Time Notifications

import { useEffect } from 'react';
import { useCloudSignalContext } from './contexts/CloudSignalContext';
import { toast } from 'your-toast-library';

export function NotificationListener({ userId }) {
  const { isConnected, messages, subscribe } = useCloudSignalContext();

  useEffect(() => {
    if (isConnected) {
      subscribe(`notifications/user/${userId}`);
    }
  }, [isConnected, userId]);

  // Show toast for new notifications
  useEffect(() => {
    const latest = messages[0];
    if (latest?.topic.startsWith('notifications/')) {
      toast[latest.payload.type || 'info'](latest.payload.body, {
        title: latest.payload.title
      });
    }
  }, [messages]);

  return null;
}

Troubleshooting

React StrictMode Issues

If you see duplicate connections in development, ensure you use the ref guards shown above. React StrictMode intentionally mounts components twice.

Auth Errors

If onAuthError fires, check:

  1. Organization ID matches your dashboard
  2. External token (from Supabase/Firebase) hasn't expired
  3. Token service URL is https://auth.cloudsignal.app

Connection Not Working

Enable debug mode to see detailed logs:

useCloudSignal({ debug: true })