Using Action Cable with React Native
Tags: rails, reactnative, javascript, react
Published: 2020-08-17 01:49:11

One of the changes with Rails 6 was [to make Action Cable work with webworkers.](https://github.com/rails/rails/pull/34941). Consequently, this also makes it possible to now use the Action Cable client javascript with React Native because it now depends on the DOM less.

That said, at the time of writing, there isn't a solid guarantee that it will continue to work.
https://github.com/rails/rails/pull/36652#issuecomment-510623557

That also said, if it does stop working, it'll most likely be caught during compilation or a very apparent error when testing the update.

So dust off your generic demo application hats, because I'm going to be showing how you can build a chat app using rails and React Native (and hopefully extend that knowledge to real apps). I'm going to assume a knowledge of javascript and Rails (or that you'll look up anything you don't know).

## Rails Project Generation

*Skip to the [React Native App Generation](#react-native-app-generation) section if you've already got a Rails project and just need to see how to hook Action Cable up to it.*

To make sure we're all on the same page I'm going to quickly go through setting up a Rails app. It's just going to be a very minimal application, and it should be easy enough to work this into an existing app.

*I'm using rails `6.0.3.2`, and ruby `2.6.3`, but the versions shouldn't matter too much.*

I've generated the application with

```
rails new ChatApp --skip-sprockets
```

### Browser Version

To make sure things are getting set up correctly, I'm going to make a really simple browser version of the chat app. This isn't really necessary, it's just for demonstration (and if you're closely following along might be useful to find out why something isn't working).

I've created a simple controller and layout like so

```rb
# app/controllers/home_controller.rb

class HomeController < ApplicationController
  def index; end
end
```

```rb
# config/routes.rb

Rails.application.routes.draw do
  root to: 'home#index'
end
```

```html
<!-- app/views/home/index.html.erb -->

<h1>Chat App</h1>

<form id="message-form">
  <input type="text" name="message" id="message">
  <input type="submit" value="Send">
</form>

<hr>

<div id="messages">
</div>
```

```js
// Added to app/javascript/packs/application.js

document.addEventListener('DOMContentLoaded', () => {
  const form = document.querySelector('#message-form');

  const formSubmitted = (e) => {
    e.preventDefault();
    const { value } = e.target.querySelector('#message');
    console.log('i will send', value);
    e.target.reset();
  };

  form.addEventListener('submit', formSubmitted);
});
```

This is all pretty straight forward. At this point when you visit the home page, you'll see a very bare page with a form. If you submit the form, the console will log `i will send X`.

### Adding Action Cable

Action Cable is included by default when running `rails new`. If you don't have anything in `app/channels`, then you'll need to set it up first. [The Rails Guide should be enough to go off of.](https://guides.rubyonrails.org/action_cable_overview.html)

Now we're going to create a channel by running this command.

```
rails g channel Chat
```

This will create `app/channels/chat_channel.rb` and `app/javascript/channels/chat_channel.js`.

After making some modifications, here are the final files I ended up with.

```rb
# app/channels/chat_channel.rb

class ChatChannel < ApplicationCable::Channel
  def subscribed
    stream_from('main_room')
    content = {
      key: SecureRandom.hex(5),
      message: 'someone has arrived'
    }
    ActionCable.server.broadcast('main_room', content: content)
  end

  def receive(data)
    content = data.merge(key: SecureRandom.hex(5))
    ActionCable.server.broadcast('main_room', content: content)
  end
end
```

Let's quickly break this down a bit.

In `ChatChannel#subscribed`, we're going to create a generic message when someone connects, then send it off to everyone in the `main_room` room. For `key` I'm just using a random unique value. This is purely just for React to have a `key` attribute; if you're saving data and have an ID or have another unique attribute then this isn't necessary.

`ChatChannel#recieve` will take in the data from the client websocket, then add a key to act as an ID and spit it back out to the clients (including the one that initially sent it).

```js
// app/javascript/channels/chat_channel.js

import consumer from './consumer';

const ChatChannel = consumer.subscriptions.create({ channel: 'ChatChannel', room: 'main_room' }, {
  received(data) {
    const messagesContainer = document.querySelector('#messages');
    const message = document.createElement('div');

    message.innerHTML = `
      <p>${data.content.message}</p>
    `;

    messagesContainer.prepend(message);
  },
});

export default ChatChannel;
```

In this file we're just connecting to the channel, and setting up a method that will run when new data is broadcasted. All this function does is add new message to the messages container.

Now we just need to send data instead of log it by using `ChatChannel.send`. Here is the final `application.js` I ended up with.

```js
// app/javascript/packs/application.js

require('@rails/ujs').start();
require('turbolinks').start();
require('@rails/activestorage').start();
require('channels');

import ChatChannel from '../channels/chat_channel'; // new

document.addEventListener('DOMContentLoaded', () => {
  const form = document.querySelector('#message-form');

  // modified
  const formSubmitted = (e) => {
    e.preventDefault();
    const { value } = e.target.querySelector('#message');
    ChatChannel.send({ message: value }); // new
    e.target.reset();
  };

  form.addEventListener('submit', formSubmitted);
});
```

Assuming everything is working, the message will be broadcasted to all the connected clients and added to the page. If you wanted, you could test this by opening up the site in multiple tabs.

_Sometimes the message 'someone has arrived' won't show on the just connected client. If it doesn't show, try reloading a few times, or using multiple tabs_

## React Native App Generation

I'm going to use [Expo](https://expo.io) for this project.

_I'm using Node version `12.18.1` and Expo `3.23.3`._

Generate a new Expo project with

```
expo init ChatAppClient --template blank
```

For the this guide, I'm going to use the iOS simulator. You should be able to use whichever platform you want.

Running `yarn ios` should eventually pop you into the iPhone simulator with a minimal app.

### Base Layout

For demonstration purposes I'm going to do everything in `App.js`.

Here is what I'm starting out with. It doesn't make any calls to the server yet, just generally sets everything up.

```jsx
// App.js

import React, { useState } from 'react';
import {
  StyleSheet,
  Text,
  View,
  TextInput,
  KeyboardAvoidingView,
  FlatList,
} from 'react-native';
import Constants from 'expo-constants';

const styles = StyleSheet.create({
  container: {
    paddingTop: Constants.statusBarHeight,
    height: '100%',
  },
  messages: {
    flex: 1,
  },
  message: {
    borderColor: 'gray',
    borderBottomWidth: 1,
    borderTopWidth: 1,
    padding: 8,
  },
  form: {
    backgroundColor: '#eee',
    paddingHorizontal: 10,
    paddingTop: 10,
    paddingBottom: 75,
  },
  input: {
    height: 40,
    borderColor: 'gray',
    borderWidth: 1,
    backgroundColor: 'white',
  },
});

const Message = ({ message }) => (
  <View style={styles.message}>
    <Text style={styles.message}>{message}</Text>
  </View>
);

const App = () => {
  const [value, setValue] = useState('');
  const [messages, setMessages] = useState([{ key: '1', message: 'hi' }]);

  const renderedItem = ({ item }) => (<Message message={item.message} key={item.key} />);
  const inputSubmitted = (event) => {
    const newMessage = event.nativeEvent.text;
    console.log('will send', newMessage);
    setValue('');
  };

  return (
    <KeyboardAvoidingView style={styles.container} behavior="height">
      <FlatList
        styles={styles.messages}
        data={messages}
        renderItem={renderedItem}
        keyExtractor={(item) => item.key}
      />

      <View style={styles.form}>
        <TextInput
          style={styles.input}
          onChangeText={text => setValue(text)}
          value={value}
          placeholder="Type a Message"
          onSubmitEditing={inputSubmitted}
        />
      </View>
    </KeyboardAvoidingView>
  );
};

export default App;
```

### Hooking Up Action Cable

Most of this process is copying what would be done in the browser.

First we need to add the Action Cable package.

```
yarn add @rails/actioncable
```

*Note: make sure you add `@rails/actioncable` instead of `actioncable`, otherwise you wont be using the Rails 6 version.*

First let's create our consumer.

```js
import { createConsumer } from '@rails/actioncable';

global.addEventListener = () => {};
global.removeEventListener = () => {};

const consumer = createConsumer('ws://localhost:5000/cable'); // the localhost url works on the iOS simulator, but will likely break on Android simulators and on actual devices.
```

We need to set global functions for `addEventListener` and `removeEventListener` because they're currently used in Action Cable to tell when the tab is in view. See [this issue](https://github.com/rails/rails/pull/36652#issuecomment-656151080) for more context.

If you want, you don't need to make these functions be empty. They just need to exist (and be functions) otherwise the code will blow up.

Another thing to point out is that we need to give `createConsumer` a URL to connect to. The protocol needs to be `ws` or `wss` otherwise, Action Cable will try to do stuff with the DOM. By default `/cable` is the path that Action Cable uses (you'll probably know if this isn't the case for you). When in doubt if you've got the right URL, just try it in the browser version, then you can see if it fails.

*Sometimes simulators (in my experience particularly the Android simulator) doesn't treat `localhost` as the same `localhost` as your browser. There are ways around it, like using a specific IP address, or using a tool like `ngrok`, or just deploying your backend somewhere. If you need to, this also works with the browser version of Expo.*

Next we need to join the channel and add incoming messages. That can be done by adding the following to the `App` component.

```js
const chatChannel = useMemo(() => {
  return consumer.subscriptions.create({ channel: 'ChatChannel', room: 'main_room' }, {
    received(data) {
      setMessages(messages => messages.concat(data.content));
    },
  });
}, []);
```

`useMemo` will run the given callback whenever one of the values in the array change. In this case, we're not actually giving any values, so it will never change. Meaning we're connecting to the channel when the `App` component first gets rendered (or just use `componentDidMount` if you're working with a class component). The value of `chatChannel` is the same `Subscription` object like what gets exported by `chat_channel.js` in the browser version.

Now all that's left is to send the message in the `inputSubmitted` function. That can be done by modifying it to look like this.

```js
const inputSubmitted = (event) => {
  const newMessage = event.nativeEvent.text;
  chatChannel.send({ message: newMessage }); // new
  setValue('');
};
```

Assuming everything is set up correctly (and an update hasn't gone out that breaks everything), you should be able to send messages between the app and browser version.

Here's the final `App.js` file I've ended up with:

```jsx
// App.js

import React, { useState, useMemo } from 'react';
import {
  StyleSheet,
  Text,
  View,
  TextInput,
  KeyboardAvoidingView,
  FlatList,
} from 'react-native';
import Constants from 'expo-constants';
import { createConsumer } from '@rails/actioncable';

global.addEventListener = () => {};
global.removeEventListener = () => {};

const consumer = createConsumer('ws://localhost:5000/cable');

const styles = StyleSheet.create({
  container: {
    paddingTop: Constants.statusBarHeight,
    height: '100%',
  },
  messages: {
    flex: 1,
  },
  message: {
    borderColor: 'gray',
    borderBottomWidth: 1,
    borderTopWidth: 1,
    padding: 8,
  },
  form: {
    backgroundColor: '#eee',
    paddingHorizontal: 10,
    paddingTop: 10,
    paddingBottom: 75,
  },
  input: {
    height: 40,
    borderColor: 'gray',
    borderWidth: 1,
    backgroundColor: 'white',
  },
});

const Message = ({ message }) => (
  <View style={styles.message}>
    <Text style={styles.message}>{message}</Text>
  </View>
);

const App = () => {
  const [value, setValue] = useState('');
  const [messages, setMessages] = useState([]);
  const chatChannel = useMemo(() => {
    return consumer.subscriptions.create({ channel: 'ChatChannel', room: 'main_room' }, {
      received(data) {
        setMessages(messages => messages.concat(data.content));
      },
    });
  }, []);

  const renderedItem = ({ item }) => (<Message message={item.message} key={item.key} />);
  const inputSubmitted = (event) => {
    const newMessage = event.nativeEvent.text;
    chatChannel.send({ message: newMessage });
    setValue('');
  };

  return (
    <KeyboardAvoidingView style={styles.container} behavior="height">
      <FlatList
        styles={styles.messages}
        data={messages}
        renderItem={renderedItem}
        keyExtractor={(item) => item.key}
      />

      <View style={styles.form}>
        <TextInput
          style={styles.input}
          onChangeText={text => setValue(text)}
          value={value}
          placeholder="Type a Message"
          onSubmitEditing={inputSubmitted}
        />
      </View>
    </KeyboardAvoidingView>
  );
};

export default App;
```