Handling WebSockets in React Native with Socket.IO: A Complete Guide

Handling WebSockets in React Native with Socket.IO: A Complete Guide

react nativewebsocketssocket.iochat application

WebSockets are essential for creating interactive, real-time apps, but using them in a mobile setting like React Native presents difficulties due to poor internet, disconnected connections, and retries.

What is socket.io?

Socket.IO is a library that enables low-latency, bidirectional and event-based communication between a client and a server. Socket.IO manages events, retries, and connection management, making WebSocket implementation easier. It is an great option for guaranteeing good event delivery as it also supports acknowledgements.

1. Setting Up the Project

Install Dependencies

First, create a React Native project and install the required dependencies:

npx @react-native-community/cli@latest init SocketIOExample
cd SocketIOExample
npm install socket.io-client

2. Creating the Socket Context

To make the socket instance globally accessible, we’ll use a React Context.

SocketContext.tsx

import React, {createContext, useContext, useEffect, useRef} from 'react';

import {AppState} from 'react-native';
import {io, Socket} from 'socket.io-client';

// Define the context type
interface Message {
  id: string;
  text: string;
}

interface SocketContextType {
  socket: Socket | null;
  isConnected: boolean;
  messages: Message[];
  setMessage: React.Dispatch<React.SetStateAction<Message[]>>;
}

const SocketContext = createContext<SocketContextType | undefined>(undefined);

export const SocketProvider: React.FC<{children: React.ReactNode}> = ({
  children,
}) => {
  const socketRef = useRef<Socket | null>(null);
  const [isConnected, setIsConnected] = React.useState(false);
  const [message, setMessage] = React.useState<Message[]>([]);

  useEffect(() => {
    socketRef.current = io('http://localhost:3000', {
      reconnection: true, // Enable automatic reconnection
      reconnectionAttempts: Infinity, // Number of reconnection attempts
      reconnectionDelay: 1000, // Initial delay between reconnection attempts (ms)
      reconnectionDelayMax: 10000, // Maximum delay between reconnection attempts (ms)
      timeout: 10000, // Connection timeout (ms)
      autoConnect: true, // Automatically connect to the server when the component mounts
      forceNew: false, // Do not force a new connection on each connection attempt
    });

    const socket = socketRef.current;

    const handleAppStateChange = (nextAppState: string) => {
      if (nextAppState === 'active') {
        socket?.connect();
      } else if (nextAppState === 'background') {
        socket?.disconnect();
      }
    };

    const appStateSubscription = AppState.addEventListener(
      'change',
      handleAppStateChange,
    );

    socket.on('connect', () => {
      console.log('Connected to server');
      setIsConnected(true);
    });

    socket.on('disconnect', reason => {
      console.warn('Disconnected:', reason);
      setIsConnected(false);
    });

    socket.on('connect_error', error => {
      console.error('Connection error:', error.message);
    });

    socket.on('message', data => {
      if (data && data.id && data.text) {
        setMessage(prevValue => [...prevValue, data]);
      } else {
        console.error('Invalid message data:', data);
      }
    });

    return () => {
      socket.disconnect();
      socket.removeAllListeners();
      appStateSubscription.remove();
    };
  }, []);

  return (
    <SocketContext.Provider
      value={{
        socket: socketRef.current,
        isConnected,
        messages: message,
        setMessage,
      }}>
      {children}
    </SocketContext.Provider>
  );
};

export const useSocket = (): SocketContextType => {
  const context = useContext(SocketContext);
  if (!context) {
    throw new Error('useSocket must be used within a SocketProvider');
  }
  return context;
};

Key Features:

  • Automatically manages connection and disconnection.
  • Provides a global socket instance.

3. Handling Acknowledgements and Event Queue

Acknowledgements ensure the server processes your events. We’ll also handle retries and queue events when disconnected.

useSocketInstance.tsx

import {useCallback, useEffect, useState} from 'react';
import {useSocket} from './SocketContext';

interface EventQueueItem {
  eventName: string;
  eventData: any;
  retryCount: number;
}

export const useSocketInstance = () => {
  const {socket, isConnected, messages: message, setMessage} = useSocket();
  const [eventQueue, setEventQueue] = useState<EventQueueItem[]>([]);

  const MAX_RETRIES = 3;
  const ACK_TIMEOUT = 5000; // Timeout in ms

  const addToQueue = (eventName: string, eventData: any, retryCount = 0) => {
    setEventQueue(prev => [...prev, {eventName, eventData, retryCount}]);
  };

  const emitWithAck = useCallback(
    async (
      eventName: string,
      eventData: any,
      retryCount = 0,
    ): Promise<void> => {
      if (!socket || !isConnected) {
        console.warn(`Socket disconnected. Queuing event: ${eventName}`);
        addToQueue(eventName, eventData, retryCount);
        return;
      }

      try {
        const response = await socket
          .timeout(ACK_TIMEOUT)
          .emitWithAck(eventName, eventData);
        if (response.status === 'ok') {
          console.log(`Event ${eventName} delivered successfully.`);
        } else {
          console.error(`Server error for event ${eventName}:`, response.error);
          throw new Error(response.error);
        }
      } catch (error) {
        console.warn(`Ack failed for event ${eventName}:`, error);
        if (retryCount < MAX_RETRIES) {
          console.log(
            `Retrying event ${eventName} (${retryCount + 1}/${MAX_RETRIES})`,
          );
          addToQueue(eventName, eventData, retryCount + 1);
        } else {
          console.error(
            `Event ${eventName} failed after ${MAX_RETRIES} retries.`,
          );
          // Notify the caller about the failure
          throw new Error(
            `Event ${eventName} failed after ${MAX_RETRIES} retries.`,
          );
        }
      }
    },
    [isConnected, socket],
  );

  const processQueue = useCallback(() => {
    if (socket && isConnected) {
      eventQueue.forEach(({eventName, eventData, retryCount}) => {
        emitWithAck(eventName, eventData, retryCount);
      });
      setEventQueue([]); // Clear the queue after processing
    }
  }, [emitWithAck, eventQueue, isConnected, socket]);

  useEffect(() => {
    if (isConnected) {
      processQueue();
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isConnected, eventQueue]); // This will rerun the effect when either the socket is connected and when there is queue to run

  return {emitWithAck, isConnected, message, setMessage};
};

4. Integrating into the App

App.tsx

import React from 'react';
import {SocketProvider} from './SocketContext';
import ChatScreen from './ChatScreen';

function App(): React.JSX.Element {
  return (
    <SocketProvider>
      <ChatScreen />
    </SocketProvider>
  );
}

export default App;

ChatScreen.tsx

import React, {useState} from 'react';
import {View, TextInput, Button, StyleSheet, Alert, Text} from 'react-native';
import {useSocketInstance} from './useSocketInstance';

const ChatScreen = () => {
  const {emitWithAck, isConnected, message} = useSocketInstance();
  const [currentMessage, setCurrentMessage] = useState('');

  const sendMessage = () => {
    if (currentMessage.trim()) {
      emitWithAck('sendMessage', {currentMessage}).catch(error => {
        console.error('Error sending message:', error);
        // Handle the error, e.g., display an error message to the user
        Alert.alert('Error sending message: ' + error.message);
      });
      setCurrentMessage('');
    }
  };

  return (
    <View style={styles.container}>
      {message.map(value => (
        <Text key={value.id}>{value.text}</Text>
      ))}
      <TextInput
        value={currentMessage}
        onChangeText={setCurrentMessage}
        placeholder="Type a message"
        style={styles.input}
      />
      <Button
        title="Send Message"
        onPress={sendMessage}
        disabled={!isConnected}
      />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {padding: 20},
  input: {
    borderWidth: 1,
    borderColor: '#ccc',
    borderRadius: 5,
    marginBottom: 10,
    padding: 10,
  },
});

export default ChatScreen;

Conclusion

This setup ensures:

  • Reliable Event Delivery: Acknowledgements with retries and timeouts ensure messages are not lost.
  • Reconnection Management: Automatically processes queued events when the connection is restored.
  • Global Access: A centralized socket context simplifies application logic.
  • With this robust approach, your React Native app can handle WebSocket communication even under unreliable network conditions.

Github Link: https://github.com/dubbyding/react-native-socket