Using Action Cable in React (Native)

Jacco van der Plaat - 3 February 2022

While the basics of the Rails Action Cable aren't hard to understand, it can quickly become a hassle to keep the usage of Action Cable simple. Custom hooks can greatly simplify the process!

Using a custom hook means there's a lot of things, such as cleanup on disconnect, we only have to implement once.

Dependencies

There's only two things you need to get started, React and Rails Action Cable. Since this is a post about using Action Cable in React (Native), I'm going ahead and assume you already have React installed, so continue with installing Action Cable.

npm install --save @rails/actioncable

Connecting to Action Cable

Creating a connection to Action Cable is pretty straightforward. The actioncable package provides a 'createConsumer' method that we can use.

createConsumer(url)

We're going to use this method, but we are creating a hook for it first. So create the file hooks/useActionCable.js and add the following code:

import React, { useEffect, useMemo } from 'react'
import { createConsumer } from '@rails/actioncable'

export default function useActionCable(url) {
  const actionCable = useMemo(() => createConsumer(url), [])

  useEffect(() => {
    return () => {
      console.log('Disconnect Action Cable')
      actionCable.disconnect()
    }
  }, [])

  return { actionCable }
}

That's all the is to setting up the connection (spoiler, this doesn't really do anything just yet.) The useMemo allows us to export the actionCable constant for future use. The useEffect ensures that the Action Cable is properly disconnected when the application is closed.

Connecting to a channel

Now that we have a useActionCable hook, we can continue to setting up a connection to a channel. So, create the file hooks/useChannel.js. There's quite a bit more to this hook than there was to the useActionCable hook, so we're going to add the code step by step. We start by adding some of the basics.

import React, { useState, useEffect, useRef } from 'react'

// Needed for @rails/actioncable
global.addEventListener = () => {};
global.removeEventListener = () => {};

export default function useChannel(actionCable) {
  const [connected, setConnected] = useState(false)
  const [subscribed, setSubscribed] = useState(false)
  const channelRef = useRef()

  return {subscribe, unsubscribe, send}
}

We have some state variables that track whether or not we are subscribed and connected to the websocket and a channelRef that tracks our current channel within the component.

As you can see we plan on returning some functions that are not defined yet, so lets continue and add the subscribe functionality.

const subscribe = (data, callbacks) => {
  console.log(`useChannel - INFO: Connecting to ${data.channel}`)
  const channel = actionCable.subscriptions.create(data, {
    received: (x) => {
      if (callbacks.received) callbacks.received(x)
    },
    initialized: () => {
      console.log('useChannel - INFO: Init ' + data.channel)
      setSubscribed(true)
      if (callbacks.initialized) callbacks.initialized()
    },
    connected: () => {
      console.log('useChannel - INFO: Connected to ' + data.channel)
      setConnected(true)
      if (callbacks.connected) callbacks.connected()
    },
    disconnected: () => {
      console.log('useChannel - INFO: Disconnected')
      setConnected(false)
      if (callbacks.disconnected) callbacks.disconnected()
    }
  })
  channelRef.current = channel
}

The subscribe function accepts two parameters. The first one, data must at least contain the channel we want to subscribe to. The callbacks parameter can be empty, but you'll probably want to at least specify a received callback (more later). Basically what we do is create a subscription to the channel specified in data and do something when things happen on the websocket. After create a subscription, the first thing to happen is initialized. That means the subscription is created, and at that point we set the state of subscription to true. After that Action Cable will attempt to connect and when that happens we will set the state of connected to true. When the socket gets disconnected we do the opposite. Finally we set the channelRef to the channel we subscribed to.

Similar to what we did on the useActionCable hook, we also want to add some cleanup to this hook. So lets add a useEffect and a method that can do that.

useEffect(() => {
  return () => {
    unsubscribe()
  }
}, [])

const unsubscribe = () => {
  setSubscribed(false)
  if(channelRef.current) {
    console.log('useChannel - INFO: Unsubscribing from ' + channelRef.current.identifier)
    actionCable.subscriptions.remove(channelRef.current)
    channelRef.current = null
  }
}

Besides automatically unsubscribing when we close the application (or component where this hook is used), we can also use the unsubscribe method to manually unsubscribe from anywhere we use the hook.

Now that we have created all the functionality to connect, subscribe, and receive data, it's time to create a method to send messages to Action Cable. We could simply create a method that sends a message to the socket (channeRef.current.perform(type, payload)), but we will implement some checks so that we can handle any potential errors.

const send = (type, payload) => {
  if (subscribed && !connected) throw 'useChannel - ERROR: not connected'
  if (!subscribed) throw 'useChannel - ERROR: not subscribed'
  try {
    channelRef.current.perform(type, payload)
  } catch (e) {
    throw 'useChannel - ERROR: ' + e
  }
}

First we check the subscription and connection of the socket and throw the appriopriate errors if something is wrong. Then we attempt to send the message we want to send and we throw an error if it doesn't work for any reason.

That's our custom hooks created, now we can move on to using them!

Using the hooks

Now that we've got all the complicated stuff out of the way, we can use our custom hooks to connect to an Action Cable and easily subscribe to a channel.

The below examples use React Native. Have a look at our Github repository for an example in ReactJS.

From anywhere in your application you can import the hooks and connect to where you want. For example, if you want to use a websocket in your App.js.

import React, { useEffect, useState } from 'react';
import { View, Text, Button } from 'react-native'
import useActionCable from './hooks/useActionCable';
import useChannel from './hooks/useChannel';

export default function App() {
  const {actionCable} = useActionCable('ws://localhost:3000/cable')
  const {subscribe, unsubscribe, send} = useChannel(actionCable)
  const [data, setData] = useState(null)

  useEffect(() => {
    subscribe({channel: 'YourChannel'}, {
      received: (x) => setData(x)
    })
    return () => {
      unsubscribe()
    }
  }, [])

  return (
    <View>
      <Text>{JSON.stringify(data)}</Text>
    </View>
  );
}

This is a simple example that connects to an Action Cable running on localhost and subscribes to 'YourChannel'. When a message is received it sets the data state to the received message.

You obviously might also want to send data to the websocket, so you can add a button that does exactly that.

<View>
  <Text>{JSON.stringify(data)}</Text>
  <Button title="Click!" onPress={() => send('click', { time: Date.now() })} />
</View>

Because we already imported the send method, we can simply call this and it will send the action 'click' to the socket with the payload { time: Date.now() }. You can obviously send anything you want!

This about wraps it up for this post. Obviously there are many changes you can make and many different ways you can use this. For example, you might want to use a single Action Cable throughout your entire application (hint: have a look at using a context for that).

Hope you enjoyed this post, good luck and happy coding!

We have an NPM package available that does all of the things above and more: Github NPM